Files

311 lines
16 KiB
Python
Raw Permalink 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.
"""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"]