From 0b6c4afb777826186069a6c8b8343ef2b344e1f1 Mon Sep 17 00:00:00 2001 From: shijing Date: Thu, 23 Jul 2026 10:01:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E9=92=88=E5=AF=B9=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E4=B8=8D=E5=87=86=E7=9A=84=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/chat.py | 86 +++++++++++++++++--------- backend/app/integrations/adp_client.py | 17 ++++- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index aa25820..7061908 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -182,11 +182,12 @@ def stream_chat( # 只传角色码时,其他单位的专属文档也会被 ADP 一并召回喂给 LLM,LLM 会在正文写"依据文档:《XX 单位规定》"; # 把整条链一起下发,是为了让本单位无对应制度时,上级单位的制度也在候选内,由 LLM 按"就近优先"选用. # 兄弟单位(不在向上链条上的)始终不进 allowed_tags,不会被召回.超管不过滤. + unit_chain_list = build_unit_chain(current_user) if not current_user.is_superuser else [] if current_user.is_superuser: allowed_tags: list[str] = [] else: allowed_tags = [role.code for role in current_user.roles] - allowed_tags.extend(build_unit_chain(current_user)) + allowed_tags.extend(unit_chain_list) allowed_tags.extend(["所有人可见", "通用", "全体员工"]) # role_hint 必须是业务岗位(如"总院中层副职""员工"),用来匹配制度里按职级分组的条款。 @@ -298,15 +299,19 @@ def stream_chat( # 硬约束:兜底 ADP 检索层过滤失效 -> 强制 LLM 拒用 unit_chain 之外单位的专属文档. # 允许 unit_chain 内的上级单位文档(就近优先由上面 fell_back/chosen_unit 分支引导). # 优先级最高,凌驾于 FAQ 命中等所有规则.命中 FAQ 但 FAQ 内容显然只覆盖链外单位时,也必须拒答. + allowed_units_str = "、".join(f"「{u}」" for u in unit_chain_list) if unit_chain_list else f"「{own_unit}」" hint_lines.append( f"【单位隔离硬约束·最高优先级】提问人所属单位:{own_unit}。" + f"允许的单位标签白名单(精确匹配,禁止子串/字面前缀扩展):{allowed_units_str}。" f"允许引用的文档必须满足以下之一:" - f"(a) 标签值等于{own_unit};" - f"(b) 标签值为{own_unit}的上级单位(逐级向上);" - f"(c) 标签值为'通用/全体员工/所有人可见'等公开范围。" + f"(a) 标签值精确等于白名单中任一单位名;" + f"(b) 标签值为'通用/全体员工/所有人可见'等公开范围。" + f"特别注意:标签名之间的字面前缀相同不代表存在上下级关系。" + f"例如'中国建材总院母公司'与'中国建材总院北京分公司'虽同以'中国建材总院'开头," + f"但两者是平级独立单位,非上下级关系,禁止互相引用对方专属文档。" f"不满足则:" f"(1) 禁止将其内容用于作答,禁止在'依据文档'中列出;" - f"(2) 若召回文档全部不满足(即本单位及其上级链上均无对应制度),必须回复原文:" + f"(2) 若召回文档全部不满足(即白名单单位及公开范围均无对应制度),必须回复原文:" f"'知识库中未找到 {own_unit} 及其上级单位对应的规定,建议咨询本单位财务部门。';" f"(3) 此规则覆盖'FAQ 命中即返回标准答案'的短路,即使命中 FAQ 也须先判定单位适用性。" ) @@ -318,6 +323,19 @@ def stream_chat( ) else: adp_question = raw_question + # 兜底:allowed_titles 是空集(public_only 且公开也无匹配) -> 单位链上和公开都没文档可用. + # 直接返回硬约束里那句拒答话术, 不再走 ADP 生成, 避免 LLM 自行发挥引用兄弟单位文档. + refuse_directly = ( + not is_admin + and own_unit + and isinstance(allowed_titles, set) + and len(allowed_titles) == 0 + ) + refuse_text = ( + f"知识库中未找到 {own_unit} 及其上级单位对应的规定,建议咨询本单位财务部门。" + if refuse_directly else "" + ) + def event_stream(): answer_parts: list[str] = [] usage = {"token_usage": 0, "pu_usage": 0} @@ -326,32 +344,40 @@ def stream_chat( stream_start = datetime.utcnow() failed = False error_message: str | None = None + if refuse_directly: + answer_parts.append(refuse_text) + yield f"event: answer.delta\ndata: {json.dumps(refuse_text, ensure_ascii=False)}\n\n" + yield f"event: answer.usage\ndata: {json.dumps(usage, ensure_ascii=False)}\n\n" + yield f"event: answer.done\ndata: {json.dumps({}, ensure_ascii=False)}\n\n" try: - for event in AdpClient().stream_answer( - adp_question, - user_id=str(current_user_id), - conversation_id=adp_conversation_id, - allowed_tags=allowed_tags, - image_urls=image_urls, - ): - event_name = event["event"] - if event_name == "answer.delta": - answer_parts.append(event["data"]) - elif event_name == "answer.reference": - title = (event["data"].get("source_title") or "").strip() - if not title: - continue - # 按用户所属单位链过滤引用;allowed_titles=None 时不过滤 - if allowed_titles is not None and title not in allowed_titles: - continue - key = (title, (event["data"].get("snippet") or "").strip()) - if key in seen_refs: - continue - seen_refs.add(key) - references.append(event["data"]) - elif event_name == "answer.usage": - usage.update(event["data"]) - yield f"event: {event_name}\ndata: {json.dumps(event['data'], ensure_ascii=False)}\n\n" + if not refuse_directly: + for event in AdpClient().stream_answer( + adp_question, + user_id=str(current_user_id), + conversation_id=adp_conversation_id, + allowed_tags=allowed_tags, + unit_name=own_unit, + unit_chain=unit_chain_list, + image_urls=image_urls, + ): + event_name = event["event"] + if event_name == "answer.delta": + answer_parts.append(event["data"]) + elif event_name == "answer.reference": + title = (event["data"].get("source_title") or "").strip() + if not title: + continue + # 按用户所属单位链过滤引用;allowed_titles=None 时不过滤 + if allowed_titles is not None and title not in allowed_titles: + continue + key = (title, (event["data"].get("snippet") or "").strip()) + if key in seen_refs: + continue + seen_refs.add(key) + references.append(event["data"]) + elif event_name == "answer.usage": + usage.update(event["data"]) + yield f"event: {event_name}\ndata: {json.dumps(event['data'], ensure_ascii=False)}\n\n" except Exception as exc: # noqa: BLE001 failed = True error_message = str(exc) or exc.__class__.__name__ diff --git a/backend/app/integrations/adp_client.py b/backend/app/integrations/adp_client.py index 65e16c2..09f0250 100644 --- a/backend/app/integrations/adp_client.py +++ b/backend/app/integrations/adp_client.py @@ -277,6 +277,8 @@ class AdpClient: user_id: str | None = None, conversation_id: str | None = None, allowed_tags: list[str] | None = None, + unit_name: str | None = None, + unit_chain: list[str] | None = None, image_urls: list[str] | None = None, ) -> Iterator[dict]: if self._settings.adp_provider == "tencent": @@ -285,6 +287,8 @@ class AdpClient: user_id=user_id or "anonymous", conversation_id=conversation_id or uuid.uuid4().hex, allowed_tags=allowed_tags or [], + unit_name=unit_name or "", + unit_chain=unit_chain or [], image_urls=image_urls or [], ) else: @@ -318,6 +322,8 @@ class AdpClient: user_id: str, conversation_id: str, allowed_tags: list[str], + unit_name: str = "", + unit_chain: list[str] | None = None, image_urls: list[str] | None = None, ) -> Iterator[dict]: s = self._settings @@ -329,8 +335,17 @@ class AdpClient: if not text: text = "请回答这张图片相关的问题。" contents: list[dict] = [{"Type": "text", "Text": text}] + custom_vars: dict[str, str] = {} if allowed_tags: - contents.append({"Type": "custom_variables", "CustomVariables": {"allowed_tags": ",".join(allowed_tags)}}) + custom_vars["allowed_tags"] = ",".join(allowed_tags) + # unit_name / unit_chain 是 ADP 系统 prompt 里"单位隔离硬约束"的判定依据. + # 不传等于让 LLM 靠字面前缀猜, 会把"中国建材总院母公司"误认成"中国建材总院北京分公司"的上级. + if unit_name: + custom_vars["unit_name"] = unit_name + if unit_chain: + custom_vars["unit_chain"] = ",".join(unit_chain) + if custom_vars: + contents.append({"Type": "custom_variables", "CustomVariables": custom_vars}) body = { "RequestId": uuid.uuid4().hex, "ConversationId": conversation_id,