Files
ai-contract-platform/backend/app/services/rag_service.py
T
2026-04-24 13:44:17 +08:00

854 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
]