feat(server): add workbench API, DSH planner worker and RAG pipeline

This commit is contained in:
yi
2026-09-16 19:06:20 +08:00
parent 72fc5b47c7
commit d4b5b3f43a
122 changed files with 31906 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
"""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"]