"""AI 辅助创建(小白友好):大白话 → 结构化草稿 + 资源冲突检测。 两个能力: 1. draft_from_blab:用户用自己的话描述「我想要一个干什么的 Agent / 技能 / 模板」, 由当前默认模型解析成结构化草稿(名称/说明/规则/指令/提示模板字段), 用户在表单里确认修改后再正式创建——AI 只出草稿,不直接落库。 2. conflict_scan:创建/修改前的冲突检测,回答「Agent、技能、模板之间会不会打架」: - 重名/高相似名称 - 同名约束互相矛盾(规则 vs 技能 vs 模板约束) - 关键词互斥(如「口语化」对「书面语」「输出 JSON」对「段落正文」) 冲突不阻断创建,返回 warning 列表由前端展示,用户判断后可继续。 """ from __future__ import annotations import re from difflib import SequenceMatcher from sqlmodel import Session, select from .llm import chat_json from .models import Agent, AgentSkill, AgentPromptTemplate # 语义互斥对:同一条消息里同时要求两边就会打架。 MUTUAL_EXCLUSIVE_PAIRS: list[tuple[str, str]] = [ ("口语", "书面"), ("大白话", "书面语"), ("口语", "公文"), ("简短", "详细"), ("精炼", "展开"), ("长文", "短句"), ("严肃", "幽默"), ("专业", "接地气"), ("正式", "轻松"), ("列表", "段落"), ("分点", "一段到底"), ("json", "口语"), ("markdown", "纯文本"), ("提问", "陈述"), ("第一人称", "第三人称"), ] DRAFT_SYSTEM_PROMPT = """你是私域Agent系统的配置助手。用户不懂 AI,只会用自己的话描述想要的东西。 你的任务是把用户的描述整理成结构化草稿,输出 JSON。 输出要求: - 只输出一个合法 JSON 对象,不要 markdown 代码块标记,不要解释。 - 名称要具体(4-12 个字),说明是一句完整的话。 - 用户没提到的字段用空字符串或空列表,不要编造细节。 - 规则和指令用大白话写,像跟同事交代事情,不写 AI 术语。 三种资源的 JSON 结构: 1. agent(智能体): {"kind": "agent", "name": "...", "role": "production|qc|composer|service", "desc": "...", "rules": ["规则1", "规则2"], "content_tags": ["标签"], "skill_suggestions": ["想要的能力描述"], "advice": "给用户的一句提醒或建议"} 2. skill(技能): {"kind": "skill", "name": "...", "desc": "...", "instructions": ["步骤1", "步骤2"], "advice": "..."} 3. template(提示模板): {"kind": "template", "name": "...", "desc": "...", "role_prompt": "你是...(角色设定)", "task_template": "...", "constraints": ["约束1"], "style": "文风描述", "advice": "..."} role 判断:写内容的选 production,检查内容质量的选 qc,把多段合成完整稿的选 composer, 客服对话选 service。不确定时选 production。""" # ---------------- 知识库检索注入 ---------------- def _kb_ids_for_kind(session: Session, kind: str) -> list[int]: """按草稿类型挑选参与检索的知识库: agent/skill/template 都查全部业务库——草稿可能涉及任何板块; 检索相关性排序会自动挑最相关的块。""" from .models import KnowledgeBase names = {"心法", "技法", "功法", "情欲内容", "产品信息", "销售逻辑"} out = [] for kb in session.exec(select(KnowledgeBase)).all(): if kb.name in names: out.append(kb.id) return out def retrieve_kb_context(session: Session, description: str, kind: str, k: int = 8) -> tuple[list[dict], str]: """按描述检索知识块,返回 (hits, 注入 prompt 的上下文文本)。检索失败返回空不阻塞。""" try: from .rag import pipeline as rag_pipeline kb_ids = _kb_ids_for_kind(session, kind) if not kb_ids: return [], "" hits = rag_pipeline.search_kbs_remote( session, kb_ids, description, k=k, user_id=0, organization_id=1) hits = [h for h in hits if str(h.get("text") or "").strip()][:k] if not hits: return [], "" parts = [] for i, h in enumerate(hits, 1): kb_name = str(h.get("kb_name") or "") src = str((h.get("meta") or {}).get("file") or "") text = re.sub(r"\s+", " ", str(h.get("text") or ""))[:420] parts.append(f"[知识{i}]({kb_name}·{src}){text}") return hits, "\n\n".join(parts) except Exception as exc: # 检索失败不阻塞草稿生成 print(f"[ai-assist] 知识库检索失败(不影响生成): {exc}", flush=True) return [], "" def _pick_model(session: Session, organization_id: int | None = None) -> tuple[int, str] | None: """挑一个可用的 Provider/模型:优先全局默认,其次任意启用的。""" from .models import ModelProvider, ModelCatalog, AppSetting setting = session.exec(select(AppSetting).where(AppSetting.key == "model_default")).first() if setting: try: data = __import__("json").loads(setting.value) provider = session.get(ModelProvider, int(data.get("provider_id") or 0)) if provider and provider.enabled and (organization_id is None or int(provider.organization_id or 1) in {0, int(organization_id)} ) and data.get("model_name"): return provider.id, str(data["model_name"]) except (ValueError, TypeError, KeyError): pass # Do not fall back to the first enabled built-in provider: it may have no # development credential while a user-configured provider is usable. from .secret_store import resolve_secret providers = session.exec(select(ModelProvider).where(ModelProvider.enabled == True)).all() # noqa: E712 if organization_id is not None: providers = [p for p in providers if int(p.organization_id or 1) in {0, int(organization_id)}] for provider in providers: if provider.default_model and resolve_secret(provider.api_key, f"provider-{provider.id}"): return provider.id, provider.default_model return None def draft_from_blab(session: Session, kind: str, description: str, user_id: int = 0, organization_id: int = 1, advice: str = "", previous: dict | None = None) -> dict: """大白话 → 结构化草稿。无可用模型时抛 ValueError(前端显示可读提示)。 advice+previous:人工未确认时的"按建议重新生成"通道——意见最高优先级注入。""" description = str(description or "").strip() if not description: raise ValueError("请先用一段话描述你想要的东西") if len(description) < 5: raise ValueError("描述太短了,多说几句你想要它干什么、风格是什么") if kind not in {"agent", "skill", "template"}: raise ValueError("不支持的资源类型") model = _pick_model(session) if not model: raise ValueError("还没有可用的模型,请先到「模型管理」添加 Provider") provider_id, model_name = model hits, kb_context = retrieve_kb_context(session, description, kind) user_msg = f"类型:{kind}\n用户说:{description[:2000]}" if advice: user_msg += f"\n\n【人工修改意见(最高优先级,必须逐条落实,输出修改后的完整草稿)】\n{advice[:1500]}" if previous: import json as _json user_msg += "\n\n【上一版草稿(在其基础上按意见修改)】\n" + _json.dumps(previous, ensure_ascii=False)[:2500] if kb_context: user_msg += ("\n\n【系统知识库相关内容(供你理解业务语境,草稿的规则/指令应贴合这些真实知识)】\n" + kb_context) result = chat_json( session, [{"role": "system", "content": DRAFT_SYSTEM_PROMPT}, {"role": "user", "content": user_msg}], provider_id, model_name, agent_name="AI 辅助创建", user_id=user_id, organization_id=organization_id, max_retries=1, temperature=0.4, ) if result.get("status") != "ok" or not isinstance(result.get("json"), dict): raise ValueError(f"AI 没能理解这段描述:{str(result.get('error'))[:160]}") draft = result["json"] draft["kind"] = kind # 模型信息回传,前端展示这次草稿花了多少 draft["_meta"] = {"provider": result.get("provider"), "model": result.get("model"), "tokens": result.get("tokens"), "duration_ms": result.get("duration_ms"), "kb_hits": [{"kb_id": h.get("kb_id"), "kb_name": h.get("kb_name"), "file": str((h.get("meta") or {}).get("file") or ""), "excerpt": re.sub(r"\s+", " ", str(h.get("text") or ""))[:80]} for h in hits]} return draft # ---------------- 冲突检测 ---------------- def _norm(text: str) -> str: return re.sub(r"[\s,。、!?:;「」\-—_]", "", str(text or "")).lower() def _similar(a: str, b: str) -> float: return SequenceMatcher(None, _norm(a), _norm(b)).ratio() def _find_mutex_clauses(text: str, known_conflicts: list[str]) -> list[str]: """在一段文本里找互斥表述。返回「A ↔ B」列表。""" found: list[str] = [] norm = _norm(text) for left, right in MUTUAL_EXCLUSIVE_PAIRS: l, r = _norm(left), _norm(right) if l in norm and r in norm: pair = f"{left} ↔ {right}" if pair not in known_conflicts: found.append(pair) return found def _single_side_hits(text: str) -> set[str]: """文本里出现的互斥词(任一侧),用于跨资源单侧冲突比对。""" norm = _norm(text) hits: set[str] = set() for left, right in MUTUAL_EXCLUSIVE_PAIRS: if _norm(left) in norm: hits.add(_norm(left)) if _norm(right) in norm: hits.add(_norm(right)) return hits def conflict_scan(session: Session, kind: str, payload: dict, exclude_id: int = 0) -> dict: """扫描与现有资源的冲突。返回 {conflicts: [...], warnings: [...]}。 conflicts 是应该处理的(重名),warnings 是提醒用户判断的(相似名/互斥表述)。 """ conflicts: list[str] = [] warnings: list[str] = [] name = str(payload.get("name") or "").strip() norm_name = _norm(name) if not norm_name: return {"conflicts": ["名称不能为空"], "warnings": []} # 1. 同类重名 → conflict if kind == "agent": rows = [(a.id, a.name, a) for a in session.exec(select(Agent)).all()] elif kind == "skill": rows = [(sk.id, sk.name, sk) for sk in session.exec(select(AgentSkill)).all()] else: rows = [(t.id, t.name, t) for t in session.exec(select(AgentPromptTemplate)).all()] for row_id, row_name, _row in rows: if row_id == exclude_id: continue if _norm(row_name) == norm_name: conflicts.append(f"已存在同名的{'Agent' if kind == 'agent' else '技能' if kind == 'skill' else '提示模板'}「{row_name}」,换一个名字") continue if _similar(name, row_name) >= 0.82: warnings.append(f"与现有「{row_name}」名字很像(相似度 {int(_similar(name, row_name) * 100)}%),确认不是重复创建?") # 2. 互斥表述:收集本资源的所有文字 own_texts: list[str] = [name, str(payload.get("desc") or "")] if kind == "agent": own_texts += [str(item) for item in (payload.get("rules") or [])] elif kind == "skill": own_texts += [str(item) for item in (payload.get("instructions") or [])] else: own_texts += [str(payload.get("role_prompt") or ""), str(payload.get("task_template") or ""), str(payload.get("style") or "")] own_texts += [str(item) for item in (payload.get("constraints") or [])] own_conflicts: list[str] = [] for text in own_texts: for pair in _find_mutex_clauses(text, own_conflicts): own_conflicts.append(pair) if own_conflicts: warnings.append("这段描述里有互相打架的要求:" + ";".join(f"「{pair}」" for pair in own_conflicts) + ",AI 不知道该听哪个") # 3. Agent 与挂接技能的冲突(创建/编辑 Agent 时) if kind == "agent": skill_ids = [int(item) for item in (payload.get("skill_ids") or []) if str(item).isdigit()] for skill_id in skill_ids: skill = session.get(AgentSkill, skill_id) if not skill: continue skill_text = skill.instructions own_pairs: set[tuple[str, str]] = set() for text in own_texts: own_pairs.update(_find_mutex_clauses(text, [])) skill_pairs = set(_find_mutex_clauses(skill_text, [])) # 双侧都在(两边各自出现完整互斥对)→ 直接冲突 for l, r in own_pairs: for l2, r2 in skill_pairs: if _norm(l) in (_norm(l2), _norm(r2)) or _norm(r) in (_norm(l2), _norm(r2)): warnings.append(f"与挂接的技能「{skill.name}」要求相反:{l} ↔ {r},生成时会互相拉扯") break # 单侧词冲突:本资源只说「口语」,技能只说「书面」→ 两边拉扯 own_sides: set[str] = set() for text in own_texts: own_sides.update(_single_side_hits(text)) skill_sides = _single_side_hits(skill_text) for l, r in MUTUAL_EXCLUSIVE_PAIRS: if _norm(l) in own_sides and _norm(r) in skill_sides: warnings.append(f"与挂接的技能「{skill.name}」要求相反:{l} ↔ {r},生成时会互相拉扯") elif _norm(r) in own_sides and _norm(l) in skill_sides: warnings.append(f"与挂接的技能「{skill.name}」要求相反:{r} ↔ {l},生成时会互相拉扯") # 4. Agent 与其提示模板的约束冲突 if kind == "agent": template_id = payload.get("prompt_template_id") if template_id and str(template_id).isdigit(): template = session.get(AgentPromptTemplate, int(template_id)) if template: template_texts = [template.role_prompt, template.task_template, template.style] + list(template.constraints or []) template_sides: set[str] = set() for text in template_texts: template_sides.update(_single_side_hits(text)) own_sides_t: set[str] = set() for text in own_texts: own_sides_t.update(_single_side_hits(text)) for l, r in MUTUAL_EXCLUSIVE_PAIRS: if _norm(l) in own_sides_t and _norm(r) in template_sides: warnings.append(f"与提示模板「{template.name}」的约束相反:{l} ↔ {r},模板会跟规则打架") elif _norm(r) in own_sides_t and _norm(l) in template_sides: warnings.append(f"与提示模板「{template.name}」的约束相反:{r} ↔ {l},模板会跟规则打架") return {"conflicts": conflicts, "warnings": warnings} def conflict_scan_text(session: Session, kind: str, payload: dict, exclude_id: int = 0) -> dict: """conflict_scan 的文本摘要版,供 LLM 草稿后立即回检。""" return conflict_scan(session, kind, payload, exclude_id) __all__ = ["draft_from_blab", "conflict_scan", "conflict_scan_text", "MUTUAL_EXCLUSIVE_PAIRS", "DRAFT_SYSTEM_PROMPT"]