4a93bd1c44
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
854 lines
38 KiB
Python
854 lines
38 KiB
Python
import math
|
||
import re
|
||
from collections import Counter
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.repositories.knowledge_base_document_repository import KnowledgeBaseDocumentRepository
|
||
|
||
CHUNK_SIZE = 400
|
||
CHUNK_OVERLAP = 80
|
||
MAX_CONTEXT_RESULTS = 3
|
||
MAX_SUMMARY_POINTS = 4
|
||
MAX_SOURCE_SNIPPET = 120
|
||
MAX_BACKUP_SOURCES = 2
|
||
MIN_MATCH_SCORE_FOR_DIRECT_ANSWER = 0.6
|
||
MIN_CONFIDENT_SOURCE_SCORE = 1.0
|
||
SENTENCE_BREAK_PATTERN = re.compile(r"(?<=[。!?.!?;;\n])")
|
||
ASCII_TOKEN_PATTERN = re.compile(r"[a-z0-9_]{2,}")
|
||
CJK_BIGRAM_PATTERN = re.compile(r"(?=([\u4e00-\u9fff]{2}))")
|
||
CJK_TRIGRAM_PATTERN = re.compile(r"(?=([\u4e00-\u9fff]{3}))")
|
||
LONG_CJK_TERM_PATTERN = re.compile(r"[\u4e00-\u9fff]{4,}")
|
||
|
||
KEY_CONTRACT_TERMS = ("合同", "条款", "违约", "付款", "保密", "续约", "免责", "发票", "交付")
|
||
CONTRACT_QUESTION_KEYWORDS = ("合同", "条款", "审查", "风险", "违约", "付款", "保密", "免责", "续约")
|
||
LEGAL_STYLE_CUES = ("建议", "核查", "风险", "条款", "合同")
|
||
KNOWN_CJK_TERMS = (
|
||
"自动续约",
|
||
"违约责任",
|
||
"违约金",
|
||
"付款节点",
|
||
"付款",
|
||
"发票",
|
||
"保密",
|
||
"免责",
|
||
"续约",
|
||
"条款",
|
||
"合同",
|
||
)
|
||
STOP_WORDS = {
|
||
"的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", "个", "上", "也", "很",
|
||
"到", "说", "要", "去", "你", "会", "着", "没有", "看", "好", "自己", "这", "关", "于",
|
||
}
|
||
|
||
TOPIC_HINTS = {
|
||
"违约": "建议重点核查违约责任、违约金计算口径和触发条件。",
|
||
"付款": "建议重点核查付款节点、验收条件与发票前置要求。",
|
||
"续约": "建议重点核查续约通知窗口、终止条件和默认处理方式。",
|
||
"免责": "建议重点核查免责范围是否过宽,以及是否排除核心义务。",
|
||
"保密": "建议重点核查保密范围、期限和违约责任。",
|
||
}
|
||
TOPIC_SECTIONS = {
|
||
"违约": {
|
||
"focus": "违约责任与违约金",
|
||
"checkpoints": ["违约触发条件是否明确", "违约金比例和计算基数是否合理", "是否保留损失举证或调整空间"],
|
||
},
|
||
"付款": {
|
||
"focus": "付款与结算安排",
|
||
"checkpoints": ["付款节点是否和验收挂钩", "发票前置条件是否明确", "逾期付款责任是否清晰"],
|
||
},
|
||
"续约": {
|
||
"focus": "续约与终止机制",
|
||
"checkpoints": ["续约通知窗口是否明确", "终止条件和退出方式是否清楚", "是否存在默认续约风险"],
|
||
},
|
||
"免责": {
|
||
"focus": "免责范围",
|
||
"checkpoints": ["免责条款是否覆盖核心义务", "是否排除故意或重大过失责任", "提示和说明义务是否充分"],
|
||
},
|
||
"保密": {
|
||
"focus": "保密义务",
|
||
"checkpoints": ["保密信息范围是否明确", "保密期限是否清楚", "违约责任和例外情形是否约定"],
|
||
},
|
||
"general": {
|
||
"focus": "合同审查建议",
|
||
"checkpoints": [
|
||
"先确认问题是否对应具体条款,而不是笼统描述。",
|
||
"如果是审查问题,优先上传合同原文或相关制度文件。",
|
||
"对于高风险条款,仍建议结合法务人工复核。",
|
||
],
|
||
},
|
||
}
|
||
BACKUP_SOURCE_LIBRARY = {
|
||
"违约": [
|
||
{"title": "审查要点:违约责任", "content": "重点核查违约金比例、计算口径、损失赔偿与触发条件是否清晰。", "score": None},
|
||
{"title": "民法典提示", "content": "约定违约金过高或过低时,当事人可请求调整。", "score": None},
|
||
],
|
||
"付款": [
|
||
{"title": "审查要点:付款安排", "content": "重点核查付款节点、验收条件、发票前置要求及逾期责任。", "score": None},
|
||
{"title": "结算管理提示", "content": "付款条款应避免只写时间、不写触发条件和验收标准。", "score": None},
|
||
],
|
||
"续约": [
|
||
{"title": "审查要点:续约机制", "content": "重点核查自动续约是否有通知窗口、退出机制和例外约定。", "score": None},
|
||
{"title": "续约风险提示", "content": "默认续约但缺少退出机制时,通常应提示人工复核。", "score": None},
|
||
],
|
||
"免责": [
|
||
{"title": "审查要点:免责条款", "content": "重点核查免责范围、适用条件及是否排除核心义务责任。", "score": None},
|
||
{"title": "格式条款提示", "content": "免除或者减轻责任的格式条款,应合理提示并避免显失公平。", "score": None},
|
||
],
|
||
"保密": [
|
||
{"title": "审查要点:保密义务", "content": "重点核查保密信息范围、保密期限和违约责任是否明确。", "score": None},
|
||
{"title": "信息保护提示", "content": "保密条款应同时明确例外披露场景和返还/销毁要求。", "score": None},
|
||
],
|
||
}
|
||
DEFAULT_BACKUP_SOURCES = [
|
||
{"title": "平台提示", "content": "当前未检索到直接匹配的知识库文档,请先补充资料后再问答。", "score": None},
|
||
{"title": "审查建议", "content": "没有具体条款原文时,AI 问答只能给出方向性建议,不能替代正式法务审核。", "score": None},
|
||
]
|
||
ACTION_GUIDANCE_LIBRARY = {
|
||
"违约": {
|
||
"priority": "优先确认违约触发条件、违约金计算口径和责任上限是否明显失衡。",
|
||
"revision": "建议把违约责任拆成触发条件、计算方式和补救期限三部分写清,避免直接使用笼统高额违约金表述。",
|
||
"negotiation": "谈判时优先争取违约金比例、责任上限和整改期,避免对核心违约情形承担无限责任。",
|
||
},
|
||
"付款": {
|
||
"priority": "优先核查付款节点是否绑定验收结果,以及发票和付款前置条件是否过重。",
|
||
"revision": "建议将付款节点与验收标准、开票要求和逾期责任拆开明确,避免只写日期不写触发条件。",
|
||
"negotiation": "谈判时优先争取验收后付款、缩短账期,并限制发票条件成为拖延付款理由。",
|
||
},
|
||
"续约": {
|
||
"priority": "优先确认是否存在自动续约、通知窗口过短或退出机制不明确的问题。",
|
||
"revision": "建议把续约通知期限、默认处理方式和终止路径单独写清,避免沉默即续约。",
|
||
"negotiation": "谈判时优先争取取消自动续约,或至少保留明确通知期和无责退出窗口。",
|
||
},
|
||
"免责": {
|
||
"priority": "优先检查免责条款是否覆盖核心义务,或排除了故意、重大过失责任。",
|
||
"revision": "建议收窄免责适用范围,并明确不得免除故意、重大过失及核心履约义务责任。",
|
||
"negotiation": "谈判时应重点限制免责边界,避免对方用格式条款一并免除主要责任。",
|
||
},
|
||
"保密": {
|
||
"priority": "优先核查保密信息范围、期限、例外披露和违约责任是否完整。",
|
||
"revision": "建议把保密对象、允许披露情形、返还销毁义务和违约责任分别写明。",
|
||
"negotiation": "谈判时优先争取缩小保密范围、限定期限,并保留法定披露和内部合规使用例外。",
|
||
},
|
||
"general": {
|
||
"priority": "优先定位最直接影响履约和付款安全的高风险条款,再决定处理顺序。",
|
||
"revision": "建议先把模糊、失衡或缺少触发条件的条款改写为可执行表述。",
|
||
"negotiation": "谈判时优先聚焦高风险条款,区分必须坚持、可让步和可交换条件。",
|
||
},
|
||
}
|
||
CONTRACT_RISK_LABELS = {"high": "高风险", "medium": "中风险", "low": "低风险"}
|
||
CONTRACT_REVIEW_DISCLAIMER = "以下意见仅作为合同审查辅助建议,不替代正式法律意见。"
|
||
FALLBACK_RISK_MESSAGE = "当前知识库未命中直接证据,以下建议基于合同审查通用要点生成。"
|
||
FALLBACK_ANALYSIS_MESSAGE = "建议先定位具体条款,再结合制度文件或历史模板进行核对。"
|
||
ANALYSIS_HEADLINE = "审查结论"
|
||
RISK_HEADLINE = "风险提示"
|
||
SUMMARY_HEADLINE = "重点摘要"
|
||
CHECKLIST_HEADLINE = "建议核查点"
|
||
PRIORITY_HEADLINE = "优先处理项"
|
||
REVISION_HEADLINE = "修改建议"
|
||
NEGOTIATION_HEADLINE = "谈判建议"
|
||
SOURCE_HEADLINE = "可参考来源"
|
||
CHUNK_SEPARATOR = "\n\n"
|
||
|
||
|
||
def _dedupe_preserve_order(items: List[str]) -> List[str]:
|
||
seen = set()
|
||
result: List[str] = []
|
||
for item in items:
|
||
if item in seen:
|
||
continue
|
||
seen.add(item)
|
||
result.append(item)
|
||
return result
|
||
|
||
|
||
def _split_into_chunks(content: str) -> List[str]:
|
||
normalized = content.strip()
|
||
if not normalized:
|
||
return []
|
||
if len(normalized) <= CHUNK_SIZE:
|
||
return [normalized]
|
||
|
||
sentences = [segment.strip() for segment in SENTENCE_BREAK_PATTERN.split(normalized) if segment.strip()]
|
||
if not sentences:
|
||
sentences = [normalized]
|
||
|
||
chunks: List[str] = []
|
||
current = ""
|
||
for sentence in sentences:
|
||
candidate = f"{current}{sentence}" if current else sentence
|
||
if current and len(candidate) > CHUNK_SIZE:
|
||
chunks.append(current.strip())
|
||
overlap = current[-CHUNK_OVERLAP:] if len(current) > CHUNK_OVERLAP else current
|
||
current = f"{overlap}{sentence}".strip()
|
||
else:
|
||
current = candidate
|
||
|
||
if current:
|
||
chunks.append(current.strip())
|
||
|
||
return [chunk for chunk in chunks if chunk]
|
||
|
||
|
||
def _extract_topic_hints(query: str) -> List[str]:
|
||
hints = [hint for keyword, hint in TOPIC_HINTS.items() if keyword in query]
|
||
return _dedupe_preserve_order(hints)
|
||
|
||
|
||
class RAGService:
|
||
"""基于分段 + BM25 风格排序的检索服务。"""
|
||
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
self.document_repository = KnowledgeBaseDocumentRepository(db)
|
||
|
||
def search_relevant_documents(self, tenant_id: str, query: str, limit: int = 5) -> List[Dict]:
|
||
documents = self.document_repository.list_all_for_tenant(tenant_id)
|
||
if not documents:
|
||
return []
|
||
|
||
query_tokens = self._extract_keywords(query)
|
||
if not query_tokens:
|
||
return []
|
||
|
||
tokenized_chunks: List[Tuple[object, str, List[str]]] = []
|
||
document_frequency: Counter[str] = Counter()
|
||
total_length = 0
|
||
|
||
for doc in documents:
|
||
if not doc.content_excerpt:
|
||
continue
|
||
for chunk_content in _split_into_chunks(doc.content_excerpt):
|
||
tokens = self._extract_keywords(chunk_content)
|
||
if not tokens:
|
||
continue
|
||
tokenized_chunks.append((doc, chunk_content, tokens))
|
||
total_length += len(tokens)
|
||
for token in set(tokens):
|
||
document_frequency[token] += 1
|
||
|
||
if not tokenized_chunks:
|
||
return []
|
||
|
||
average_doc_length = total_length / len(tokenized_chunks)
|
||
query_counter = Counter(query_tokens)
|
||
results = []
|
||
|
||
for doc, chunk_content, tokens in tokenized_chunks:
|
||
score = self._calculate_bm25_score(
|
||
query_counter=query_counter,
|
||
document_tokens=tokens,
|
||
document_frequency=document_frequency,
|
||
total_documents=len(tokenized_chunks),
|
||
average_doc_length=average_doc_length,
|
||
)
|
||
if score <= 0:
|
||
continue
|
||
|
||
coverage = self._calculate_keyword_coverage(query_tokens, tokens)
|
||
bonus = self._calculate_domain_bonus(query, chunk_content)
|
||
total_score = score + coverage + bonus
|
||
results.append(
|
||
{
|
||
"id": doc.id,
|
||
"knowledge_base_id": doc.knowledge_base_id,
|
||
"file_name": doc.file_name,
|
||
"content": chunk_content,
|
||
"score": round(total_score, 4),
|
||
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||
}
|
||
)
|
||
|
||
results.sort(key=lambda item: item["score"], reverse=True)
|
||
return self._dedupe_results(results, limit)
|
||
|
||
def summarize_results(self, query: str, search_results: List[Dict]) -> Dict[str, Any]:
|
||
context, sources = self.build_context_from_results(search_results)
|
||
summary_points = self._build_summary_points(query, search_results)
|
||
review_topics = self._detect_review_topics(query, search_results)
|
||
answer = self.generate_answer_with_context(query, context)
|
||
return {
|
||
"answer": answer,
|
||
"sources": sources,
|
||
"summary": summary_points,
|
||
"review_topics": review_topics,
|
||
}
|
||
|
||
def build_chat_payload(
|
||
self,
|
||
query: str,
|
||
search_results: List[Dict],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
if not search_results:
|
||
return self.build_contract_review_fallback_payload(
|
||
query,
|
||
review_context=review_context,
|
||
)
|
||
|
||
summary = self.summarize_results(query, search_results)
|
||
if self._should_use_contract_review_style(query, search_results):
|
||
return self.build_contract_review_payload(
|
||
query,
|
||
summary,
|
||
review_context=review_context,
|
||
)
|
||
return {
|
||
"chunks": self.format_answer_chunks(summary["answer"], summary["summary"]),
|
||
"sources": summary["sources"],
|
||
}
|
||
|
||
def build_contract_review_payload(
|
||
self,
|
||
query: str,
|
||
summary: Dict[str, Any],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
review_topics = self._resolve_review_topics(summary["review_topics"], review_context)
|
||
answer = self._rewrite_answer_for_contract_review(
|
||
query,
|
||
summary["answer"],
|
||
review_topics,
|
||
review_context,
|
||
)
|
||
summary_points = self._merge_review_summary(
|
||
summary["summary"],
|
||
review_topics,
|
||
review_context,
|
||
)
|
||
review_checklist = self._build_review_checklist(review_topics, review_context)
|
||
action_sections = self._build_action_sections(review_topics, review_context)
|
||
chunks = [
|
||
f"{ANALYSIS_HEADLINE}:\n{answer}",
|
||
f"{SUMMARY_HEADLINE}:\n" + "\n".join(f"- {item}" for item in summary_points),
|
||
]
|
||
if review_checklist:
|
||
chunks.append(f"{CHECKLIST_HEADLINE}:\n" + "\n".join(f"- {item}" for item in review_checklist))
|
||
chunks.extend(self._format_action_chunks(action_sections))
|
||
chunks.append(CONTRACT_REVIEW_DISCLAIMER)
|
||
return {
|
||
"chunks": chunks,
|
||
"sources": self._decorate_sources(
|
||
summary["sources"],
|
||
review_topics,
|
||
review_context,
|
||
),
|
||
}
|
||
|
||
def build_contract_review_fallback_payload(
|
||
self,
|
||
query: str,
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
review_topics = self._resolve_review_topics(
|
||
self._detect_review_topics(query, []),
|
||
review_context,
|
||
)
|
||
summary_points = self._merge_review_summary([], review_topics, review_context)
|
||
checklist = self._build_review_checklist(review_topics, review_context)
|
||
action_sections = self._build_action_sections(review_topics, review_context)
|
||
chunks = [
|
||
f"{RISK_HEADLINE}:\n{FALLBACK_RISK_MESSAGE}",
|
||
f"{ANALYSIS_HEADLINE}:\n{FALLBACK_ANALYSIS_MESSAGE}",
|
||
]
|
||
if summary_points:
|
||
chunks.append(f"{SUMMARY_HEADLINE}:\n" + "\n".join(f"- {item}" for item in summary_points))
|
||
if checklist:
|
||
chunks.append(f"{CHECKLIST_HEADLINE}:\n" + "\n".join(f"- {item}" for item in checklist))
|
||
chunks.extend(self._format_action_chunks(action_sections))
|
||
chunks.append(CONTRACT_REVIEW_DISCLAIMER)
|
||
return {
|
||
"chunks": chunks,
|
||
"sources": self._build_backup_sources(review_topics, review_context),
|
||
}
|
||
|
||
def build_context_from_results(self, search_results: List[Dict]) -> Tuple[str, List[Dict]]:
|
||
if not search_results:
|
||
return "", []
|
||
|
||
context_parts = []
|
||
sources = []
|
||
for index, result in enumerate(search_results[:MAX_CONTEXT_RESULTS], start=1):
|
||
content = result["content"]
|
||
if len(content) > 500:
|
||
content = f"{content[:500]}..."
|
||
context_parts.append(f"[文档{index}] {content}")
|
||
sources.append({"title": f"文档: {result['file_name']}", "content": content, "score": result["score"]})
|
||
return "\n\n".join(context_parts), sources
|
||
|
||
def generate_answer_with_context(self, query: str, context: str) -> str:
|
||
if not context:
|
||
return "根据现有知识库,我暂时无法回答这个问题。请尝试上传相关文档到知识库。"
|
||
|
||
hints = _extract_topic_hints(query)
|
||
sections = [
|
||
"根据知识库中的相关文档,我为您提供以下信息:",
|
||
"",
|
||
f"针对你的问题“{query}”,我在知识库中找到了以下相关内容:",
|
||
]
|
||
if hints:
|
||
sections.extend(["", "建议重点关注:", *[f"- {hint}" for hint in hints]])
|
||
sections.extend(["", context, "", "请优先核对上述文档原文,再结合具体合同条款作出判断。"])
|
||
return "\n".join(sections)
|
||
|
||
def format_answer_chunks(self, answer: str, summary_points: List[str]) -> List[str]:
|
||
chunks = [answer]
|
||
if summary_points:
|
||
chunks.append("重点摘要:\n" + "\n".join(f"- {item}" for item in summary_points))
|
||
return chunks
|
||
|
||
def format_default_chunks(self, query: str) -> List[str]:
|
||
chunks = [
|
||
f"已收到你的问题:{query}",
|
||
"当前知识库里没有检索到直接相关的材料,建议补充制度文档、历史合同或审查规则后再试。",
|
||
]
|
||
hints = _extract_topic_hints(query)
|
||
if hints:
|
||
chunks.append("建议关注:\n" + "\n".join(f"- {hint}" for hint in hints))
|
||
return chunks
|
||
|
||
def format_default_sources(self) -> List[Dict[str, Any]]:
|
||
return [{"title": "平台提示", "content": "当前未检索到可引用的知识库文档,请补充资料后重试。", "score": None}]
|
||
|
||
def _extract_keywords(self, text: str) -> List[str]:
|
||
lowered = text.lower()
|
||
ascii_tokens = ASCII_TOKEN_PATTERN.findall(lowered)
|
||
cjk_chars = [char for char in lowered if "\u4e00" <= char <= "\u9fff" and char not in STOP_WORDS]
|
||
cjk_bigrams = [token for token in CJK_BIGRAM_PATTERN.findall(lowered) if all(char not in STOP_WORDS for char in token)]
|
||
cjk_trigrams = [token for token in CJK_TRIGRAM_PATTERN.findall(lowered) if all(char not in STOP_WORDS for char in token)]
|
||
cjk_terms = [token for token in LONG_CJK_TERM_PATTERN.findall(lowered) if len(token.strip()) >= 4]
|
||
known_terms = [term for term in KNOWN_CJK_TERMS if term in lowered]
|
||
return _dedupe_preserve_order(ascii_tokens + known_terms + cjk_terms + cjk_trigrams + cjk_bigrams + cjk_chars)
|
||
|
||
def _calculate_similarity(self, query_keywords: List[str], document_text: str) -> float:
|
||
if not query_keywords or not document_text:
|
||
return 0.0
|
||
lowered_text = document_text.lower()
|
||
score = 0.0
|
||
for keyword in query_keywords:
|
||
token = keyword.lower()
|
||
if token in lowered_text:
|
||
score += lowered_text.count(token) * max(len(token), 1)
|
||
return float(score)
|
||
|
||
def _calculate_bm25_score(
|
||
self,
|
||
query_counter: Counter[str],
|
||
document_tokens: List[str],
|
||
document_frequency: Counter[str],
|
||
total_documents: int,
|
||
average_doc_length: float,
|
||
) -> float:
|
||
if not document_tokens:
|
||
return 0.0
|
||
|
||
k1 = 1.5
|
||
b = 0.75
|
||
document_counter = Counter(document_tokens)
|
||
doc_length = len(document_tokens)
|
||
score = 0.0
|
||
for token, query_tf in query_counter.items():
|
||
term_frequency = document_counter.get(token, 0)
|
||
if term_frequency == 0:
|
||
continue
|
||
frequency = document_frequency.get(token, 0)
|
||
idf = math.log(1 + (total_documents - frequency + 0.5) / (frequency + 0.5))
|
||
numerator = term_frequency * (k1 + 1)
|
||
denominator = term_frequency + k1 * (1 - b + b * doc_length / average_doc_length)
|
||
score += idf * (numerator / denominator) * query_tf
|
||
return score
|
||
|
||
def _calculate_keyword_coverage(self, query_tokens: List[str], document_tokens: List[str]) -> float:
|
||
if not query_tokens or not document_tokens:
|
||
return 0.0
|
||
matched = len(set(query_tokens) & set(document_tokens))
|
||
return matched / max(len(set(query_tokens)), 1)
|
||
|
||
def _calculate_domain_bonus(self, query: str, content: str) -> float:
|
||
lowered_query = query.lower()
|
||
lowered_content = content.lower()
|
||
bonus = 0.0
|
||
for term in KEY_CONTRACT_TERMS:
|
||
if term in lowered_query and term in lowered_content:
|
||
bonus += 0.15
|
||
return bonus
|
||
|
||
def _build_summary_points(self, query: str, search_results: List[Dict]) -> List[str]:
|
||
points = _extract_topic_hints(query)
|
||
for item in search_results[:MAX_CONTEXT_RESULTS]:
|
||
excerpt = item["content"].replace("\n", " ").strip()
|
||
if excerpt:
|
||
points.append(excerpt[:80] + ("..." if len(excerpt) > 80 else ""))
|
||
return _dedupe_preserve_order(points)[:MAX_SUMMARY_POINTS]
|
||
|
||
def _dedupe_results(self, results: List[Dict], limit: int) -> List[Dict]:
|
||
deduped: List[Dict] = []
|
||
seen = set()
|
||
for item in results:
|
||
key = (item["file_name"], item["content"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
deduped.append(item)
|
||
if len(deduped) >= limit:
|
||
break
|
||
return deduped
|
||
|
||
def _should_use_contract_review_style(self, query: str, search_results: List[Dict]) -> bool:
|
||
lowered_query = query.lower()
|
||
if any(keyword in lowered_query for keyword in CONTRACT_QUESTION_KEYWORDS):
|
||
return True
|
||
return any(any(cue in item["content"] for cue in LEGAL_STYLE_CUES) for item in search_results[:MAX_CONTEXT_RESULTS])
|
||
|
||
def _detect_review_topics(self, query: str, search_results: List[Dict]) -> List[str]:
|
||
content = query + "\n" + "\n".join(item["content"] for item in search_results[:MAX_CONTEXT_RESULTS])
|
||
topics = [topic for topic in ("违约", "付款", "续约", "免责", "保密") if topic in content]
|
||
return topics or ["general"]
|
||
|
||
def _get_review_metadata(self, review_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||
metadata = review_context.get("metadata") if isinstance(review_context, dict) else None
|
||
return metadata if isinstance(metadata, dict) else {}
|
||
|
||
def _normalize_context_value(self, value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
return str(value).strip()
|
||
|
||
def _extract_context_topics(self, review_context: Optional[Dict[str, Any]]) -> List[str]:
|
||
metadata = self._get_review_metadata(review_context)
|
||
combined = "\n".join(
|
||
[
|
||
self._normalize_context_value(metadata.get("focusLabel")),
|
||
self._normalize_context_value(metadata.get("actionLabel")),
|
||
self._normalize_context_value(metadata.get("reviewOpinion")),
|
||
self._normalize_context_value(metadata.get("contractType")),
|
||
]
|
||
)
|
||
topics = [topic for topic in ("违约", "付款", "续约", "免责", "保密") if topic in combined]
|
||
return topics
|
||
|
||
def _resolve_review_topics(
|
||
self,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> List[str]:
|
||
merged = [
|
||
*review_topics,
|
||
*self._extract_context_topics(review_context),
|
||
]
|
||
resolved = _dedupe_preserve_order([topic for topic in merged if topic])
|
||
return resolved or ["general"]
|
||
|
||
def _build_focus_label(
|
||
self,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> str:
|
||
metadata = self._get_review_metadata(review_context)
|
||
focus_label = self._normalize_context_value(metadata.get("focusLabel"))
|
||
if focus_label:
|
||
return focus_label
|
||
|
||
focus_labels = []
|
||
for topic in review_topics:
|
||
section = TOPIC_SECTIONS.get(topic)
|
||
if section:
|
||
focus_labels.append(section["focus"])
|
||
return "、".join(_dedupe_preserve_order(focus_labels)) or TOPIC_SECTIONS["general"]["focus"]
|
||
|
||
def _build_contract_headline(
|
||
self,
|
||
query: str,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> str:
|
||
metadata = self._get_review_metadata(review_context)
|
||
focus_label = self._build_focus_label(review_topics, review_context)
|
||
action_label = self._normalize_context_value(metadata.get("actionLabel"))
|
||
if action_label:
|
||
return f"针对“{query}”,当前应围绕“{focus_label}”继续执行“{action_label}”。"
|
||
return f"针对“{query}”,当前更应从{focus_label}角度开展审查。"
|
||
|
||
def _build_context_priority_hint(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
metadata = self._get_review_metadata(review_context)
|
||
overall_risk = self._normalize_context_value(metadata.get("overallRisk"))
|
||
if overall_risk == "high":
|
||
return "当前审查上下文显示总体风险偏高,建议优先处理高风险项并尽快回到原合同条款逐项确认。"
|
||
if overall_risk:
|
||
return f"当前审查上下文显示总体风险为{overall_risk},建议结合风险等级安排处理优先级。"
|
||
return None
|
||
|
||
def _build_context_action_hint(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
metadata = self._get_review_metadata(review_context)
|
||
focus_label = self._normalize_context_value(metadata.get("focusLabel"))
|
||
action_label = self._normalize_context_value(metadata.get("actionLabel"))
|
||
if focus_label and action_label:
|
||
return f"当前追问对象是“{focus_label}”,建议输出继续用于“{action_label}”的可执行动作。"
|
||
if focus_label:
|
||
return f"当前追问对象是“{focus_label}”,建议回答围绕该对象直接给出下一步动作。"
|
||
return None
|
||
|
||
def _build_context_source_note(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
metadata = self._get_review_metadata(review_context)
|
||
focus_label = self._normalize_context_value(metadata.get("focusLabel"))
|
||
action_label = self._normalize_context_value(metadata.get("actionLabel"))
|
||
focus_type = self._normalize_context_value(metadata.get("focusType"))
|
||
parts = []
|
||
if focus_label:
|
||
parts.append(f"聚焦对象:{focus_label}")
|
||
if action_label:
|
||
parts.append(f"当前动作:{action_label}")
|
||
if focus_type == "risk_item":
|
||
parts.append("聚焦类型:风险项")
|
||
if focus_type == "clause":
|
||
parts.append("聚焦类型:条款")
|
||
return " · ".join(parts) if parts else None
|
||
|
||
def _build_context_action_sections(
|
||
self,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, List[str]]:
|
||
metadata = self._get_review_metadata(review_context)
|
||
focus_label = self._normalize_context_value(metadata.get("focusLabel")) or self._build_focus_label(review_topics, review_context)
|
||
action_label = self._normalize_context_value(metadata.get("actionLabel"))
|
||
focus_type = self._normalize_context_value(metadata.get("focusType"))
|
||
|
||
if not focus_label and not action_label:
|
||
return {}
|
||
|
||
subject = f"{focus_label}对应的合同条款" if focus_type == "clause" else focus_label or "当前审查对象"
|
||
sections = {
|
||
PRIORITY_HEADLINE: [],
|
||
REVISION_HEADLINE: [],
|
||
NEGOTIATION_HEADLINE: [],
|
||
}
|
||
|
||
sections[PRIORITY_HEADLINE].append(
|
||
f"围绕{subject}先锁定最需要优先处理的风险点,并回到 review 工作台确认对应风险项或条款位置。"
|
||
)
|
||
sections[REVISION_HEADLINE].append(
|
||
f"围绕{subject}输出可直接回到 review 页执行的修改动作,必要时补一版可替换文本。"
|
||
)
|
||
sections[NEGOTIATION_HEADLINE].append(
|
||
f"围绕{subject}准备可用于后续沟通的谈判重点、让步空间和底线。"
|
||
)
|
||
|
||
if action_label:
|
||
sections[PRIORITY_HEADLINE].append(f"当前动作是“{action_label}”,建议优先输出与该动作直接对应的下一步。")
|
||
if "解释" in action_label:
|
||
sections[PRIORITY_HEADLINE].append(f"先把{subject}为什么会触发当前风险判断解释清楚,再决定修改顺序。")
|
||
if "修改" in action_label or "改写" in action_label or "清单" in action_label:
|
||
sections[REVISION_HEADLINE].insert(0, f"先把“{action_label}”转成 review 页可直接执行的修订清单,并标出优先级。")
|
||
if "谈判" in action_label or "策略" in action_label or "底线" in action_label:
|
||
sections[NEGOTIATION_HEADLINE].insert(0, f"先围绕“{action_label}”整理可直接用于沟通的谈判顺序、底线和可交换条件。")
|
||
sections[REVISION_HEADLINE].append(f"若当前动作需要落地,建议把“{action_label}”直接转成可执行清单。")
|
||
|
||
return {key: _dedupe_preserve_order(value)[:3] for key, value in sections.items()}
|
||
|
||
def _build_source_context_note(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
return self._build_context_source_note(review_context)
|
||
|
||
def _build_backup_source_context_note(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
return self._build_context_source_note(review_context)
|
||
|
||
def _merge_source_content_with_note(self, content: str, note: Optional[str]) -> str:
|
||
base = self._truncate_source_content(content)
|
||
if not note:
|
||
return base
|
||
return f"{base}\n\n{SOURCE_HEADLINE}:{note}"
|
||
|
||
def _merge_backup_source_content_with_note(self, content: str, note: Optional[str]) -> str:
|
||
if not note:
|
||
return content
|
||
return f"{content}\n\n{SOURCE_HEADLINE}:{note}"
|
||
|
||
def _append_context_items(self, items: List[str], review_context: Optional[Dict[str, Any]] = None) -> List[str]:
|
||
hints = [
|
||
self._build_context_priority_hint(review_context),
|
||
self._build_context_action_hint(review_context),
|
||
]
|
||
return _dedupe_preserve_order([*[hint for hint in hints if hint], *items])
|
||
|
||
def _append_context_checklist(self, items: List[str], review_context: Optional[Dict[str, Any]] = None) -> List[str]:
|
||
metadata = self._get_review_metadata(review_context)
|
||
focus_label = self._normalize_context_value(metadata.get("focusLabel"))
|
||
action_label = self._normalize_context_value(metadata.get("actionLabel"))
|
||
appended = list(items)
|
||
if focus_label:
|
||
appended.append(f"回到 review 结果页时,优先核对“{focus_label}”对应的风险项或条款摘要。")
|
||
if action_label:
|
||
appended.append(f"把当前输出整理成可直接支持“{action_label}”的下一步动作。")
|
||
return _dedupe_preserve_order(appended)
|
||
|
||
def _build_topic_findings(self, review_topics: List[str]) -> List[str]:
|
||
findings = []
|
||
for topic in review_topics:
|
||
section = TOPIC_SECTIONS.get(topic)
|
||
if section:
|
||
findings.append(f"- {section['focus']}:{section['checkpoints'][0]}")
|
||
if not findings:
|
||
findings.append(f"- {TOPIC_SECTIONS['general']['focus']}:{TOPIC_SECTIONS['general']['checkpoints'][0]}")
|
||
return findings[:MAX_SUMMARY_POINTS]
|
||
|
||
def _merge_review_summary(
|
||
self,
|
||
summary_points: List[str],
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> List[str]:
|
||
merged = []
|
||
for topic in review_topics:
|
||
section = TOPIC_SECTIONS.get(topic)
|
||
if section:
|
||
merged.extend(section["checkpoints"][:2])
|
||
if not merged:
|
||
merged.extend(TOPIC_SECTIONS["general"]["checkpoints"][:2])
|
||
merged.extend(summary_points)
|
||
return self._append_context_items(_dedupe_preserve_order(merged), review_context)[:MAX_SUMMARY_POINTS]
|
||
|
||
def _build_review_checklist(
|
||
self,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> List[str]:
|
||
checklist = []
|
||
for topic in review_topics:
|
||
section = TOPIC_SECTIONS.get(topic)
|
||
if section:
|
||
checklist.extend(section["checkpoints"])
|
||
if not checklist:
|
||
checklist.extend(TOPIC_SECTIONS["general"]["checkpoints"])
|
||
return self._append_context_checklist(_dedupe_preserve_order(checklist), review_context)[:6]
|
||
|
||
def _build_action_sections(
|
||
self,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, List[str]]:
|
||
sections = {
|
||
PRIORITY_HEADLINE: [],
|
||
REVISION_HEADLINE: [],
|
||
NEGOTIATION_HEADLINE: [],
|
||
}
|
||
context_sections = self._build_context_action_sections(review_topics, review_context)
|
||
for headline, values in context_sections.items():
|
||
sections[headline].extend(values)
|
||
for topic in review_topics:
|
||
guidance = ACTION_GUIDANCE_LIBRARY.get(topic, ACTION_GUIDANCE_LIBRARY["general"])
|
||
sections[PRIORITY_HEADLINE].append(guidance["priority"])
|
||
sections[REVISION_HEADLINE].append(guidance["revision"])
|
||
sections[NEGOTIATION_HEADLINE].append(guidance["negotiation"])
|
||
return {key: _dedupe_preserve_order(value)[:3] for key, value in sections.items()}
|
||
|
||
|
||
def _format_action_chunks(self, action_sections: Dict[str, List[str]]) -> List[str]:
|
||
chunks: List[str] = []
|
||
for headline in (PRIORITY_HEADLINE, REVISION_HEADLINE, NEGOTIATION_HEADLINE):
|
||
items = action_sections.get(headline, [])
|
||
if items:
|
||
chunks.append(f"{headline}:\n" + "\n".join(f"- {item}" for item in items))
|
||
return chunks
|
||
|
||
def _normalize_analysis_sentence(self, value: str) -> str:
|
||
normalized = value.strip()
|
||
return normalized[:90] + ("..." if len(normalized) > 90 else "") if normalized else ""
|
||
|
||
def _rewrite_answer_for_contract_review(
|
||
self,
|
||
query: str,
|
||
answer: str,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> str:
|
||
normalized = answer.replace("我为您提供以下信息", "以下内容可作为合同审查辅助意见")
|
||
normalized = normalized.replace("请优先核对上述文档原文,再结合具体合同条款作出判断。", "请结合合同原文逐项比对,必要时提交法务人工复核。")
|
||
normalized = self._normalize_analysis_sentence(normalized)
|
||
sections = [self._build_contract_headline(query, review_topics, review_context)]
|
||
sections.extend(self._build_topic_findings(review_topics))
|
||
context_hint = self._build_context_action_hint(review_context)
|
||
if context_hint:
|
||
sections.append(context_hint)
|
||
if normalized:
|
||
sections.append(normalized)
|
||
return CHUNK_SEPARATOR.join(section for section in sections if section)
|
||
|
||
def _score_to_risk_label(self, score: Optional[float]) -> str:
|
||
if score is None:
|
||
return CONTRACT_RISK_LABELS["medium"]
|
||
if score >= MIN_CONFIDENT_SOURCE_SCORE:
|
||
return CONTRACT_RISK_LABELS["high"]
|
||
if score >= MIN_MATCH_SCORE_FOR_DIRECT_ANSWER:
|
||
return CONTRACT_RISK_LABELS["medium"]
|
||
return CONTRACT_RISK_LABELS["low"]
|
||
|
||
def _build_source_note(
|
||
self,
|
||
source: Dict[str, Any],
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> str:
|
||
score = source.get("score")
|
||
score_value = score if isinstance(score, (int, float)) else None
|
||
parts = [
|
||
self._build_focus_label(review_topics, review_context),
|
||
self._score_to_risk_label(score_value),
|
||
]
|
||
context_note = self._build_source_context_note(review_context)
|
||
if context_note:
|
||
parts.append(context_note)
|
||
return " · ".join([part for part in parts if part])
|
||
|
||
def _truncate_source_content(self, content: str) -> str:
|
||
return content[:MAX_SOURCE_SNIPPET] + ("..." if len(content) > MAX_SOURCE_SNIPPET else "")
|
||
|
||
def _decorate_sources(
|
||
self,
|
||
sources: List[Dict],
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
decorated = []
|
||
for source in sources:
|
||
content = str(source.get("content", ""))
|
||
decorated.append(
|
||
{
|
||
"title": str(source.get("title", "来源")),
|
||
"content": self._merge_source_content_with_note(
|
||
content,
|
||
self._build_source_note(source, review_topics, review_context),
|
||
),
|
||
"score": source.get("score"),
|
||
}
|
||
)
|
||
for source in self._build_backup_sources(review_topics, review_context):
|
||
if len(decorated) >= MAX_CONTEXT_RESULTS + MAX_BACKUP_SOURCES:
|
||
break
|
||
decorated.append(source)
|
||
return decorated
|
||
|
||
def _build_backup_sources(
|
||
self,
|
||
review_topics: List[str],
|
||
review_context: Optional[Dict[str, Any]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
sources: List[Dict[str, Any]] = []
|
||
for topic in review_topics:
|
||
sources.extend(BACKUP_SOURCE_LIBRARY.get(topic, []))
|
||
if not sources:
|
||
sources = DEFAULT_BACKUP_SOURCES.copy()
|
||
note = self._build_backup_source_context_note(review_context)
|
||
if not note:
|
||
return sources[:MAX_BACKUP_SOURCES]
|
||
return [
|
||
{
|
||
**source,
|
||
"content": self._merge_backup_source_content_with_note(
|
||
str(source.get("content", "")),
|
||
note,
|
||
),
|
||
}
|
||
for source in sources[:MAX_BACKUP_SOURCES]
|
||
]
|