aps-agent/server/agent_core/context.py

230 lines
8.8 KiB
Python
Raw Normal View History

# ============================================================
# 多轮对话上下文(moduleId: core-context, 可重生 ✅)
# 把「是的 / 好 / 那个」等短回复接到上一轮;识别文件夹解析意图。
# ============================================================
from __future__ import annotations
import os
import re
from typing import Any
HistoryItem = dict[str, str] # {role: user|agent, text: str}
def normalize_history(raw: list[Any] | None, *, limit: int = 12) -> list[HistoryItem]:
out: list[HistoryItem] = []
for m in raw or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
if role in ("assistant", "bot", "ai"):
role = "agent"
if role not in ("user", "agent"):
continue
text = str(m.get("text") or m.get("content") or "").strip()
if not text:
continue
out.append({"role": role, "text": text})
return out[-limit:]
def last_agent_text(history: list[HistoryItem]) -> str:
for m in reversed(history):
if m["role"] == "agent":
return m["text"]
return ""
def last_user_text(history: list[HistoryItem]) -> str:
for m in reversed(history):
if m["role"] == "user":
return m["text"]
return ""
def format_history_for_llm(history: list[HistoryItem], *, max_chars: int = 3500) -> str:
lines: list[str] = []
for m in history:
who = "用户" if m["role"] == "user" else "助手"
chunk = m["text"]
if len(chunk) > 500:
chunk = chunk[:500] + "…"
lines.append(f"{who}:{chunk}")
blob = "\n".join(lines)
if len(blob) > max_chars:
blob = "…\n" + blob[-max_chars:]
return blob
_AFFIRM = re.compile(
r"^(是的?|对|好|好的|行|可以|嗯+|恩+|哦+|OK|ok|yes|Yes|确认|同意|就这样|开始吧|来吧|搞起)[\s!!.。??]*$",
re.I,
)
_DENY = re.compile(r"^(不|不要|别|算了|取消|不用了|否)[\s!!.。??]*$", re.I)
def _extract_quoted_commands(agent_text: str) -> list[str]:
"""从上一轮助手回复里抠「带我排一版」这类可执行口令。"""
cmds = re.findall(r"[「『\"“]([^」』\"”]{2,24})[」』\"”]", agent_text or "")
# 常见无引号建议
for pat in (
r"带我排一版", r"直接排一版", r"帮我看看现在能不能排",
r"分析项目数据", r"跑一版柔性排产", r"我要排产",
):
if pat in (agent_text or "") and pat not in cmds:
cmds.append(pat)
return cmds
def resolve_followup(text: str, history: list[HistoryItem]) -> dict[str, Any]:
"""短回复 → 改写为明确意图;返回 {text, note, kind}。"""
t = (text or "").strip()
prev_agent = last_agent_text(history)
prev_user = last_user_text(history)
if _DENY.match(t):
return {
"text": t,
"note": "用户拒绝了上一轮建议",
"kind": "deny",
"reply": "已取消本次操作,当前数据未修改。需要继续时,请重新描述目标。",
}
if _AFFIRM.match(t) and prev_agent:
cmds = _extract_quoted_commands(prev_agent)
# 上一轮在问「你更想先做哪一步」或给了具体建议
if cmds:
chosen = cmds[0]
return {
"text": chosen,
"note": f"用户用「{t}」确认上一轮建议 → 执行「{chosen}」",
"kind": "affirm",
}
# 上一轮是分析/盘点后说「直接排一版」
if re.search(r"直接排一版|带我排一版|马上给你出", prev_agent):
return {
"text": "直接排一版",
"note": f"用户确认「{t}」→ 直接排一版",
"kind": "affirm",
}
# 上一轮在问要不要看交期/天气耽误
if re.search(r"单号|交期|耽误", prev_agent) and prev_user:
return {
"text": f"继续:{prev_user}",
"note": "用户确认继续上一话题",
"kind": "affirm",
}
return {
"text": "帮我看看现在能不能排",
"note": f"用户说「{t}」,上一轮无明确口令 → 默认检查能不能排",
"kind": "affirm",
}
# 「那个 / 上面说的 / 刚才那个」
if re.search(r"^(那个|上面|刚才|继续|再说一下|展开说说)[\s!!.。??]*$", t) and prev_user:
return {
"text": prev_user,
"note": "用户指代上一问",
"kind": "refer",
}
return {"text": t, "note": "", "kind": "plain"}
def wants_folder_listing(text: str) -> bool:
return bool(re.search(
r"文件夹|目录|项目(里|里面|内)?(的)?(文件|数据|资料)|"
r"解析.{0,8}(文件夹|目录|文件)|把.{0,6}(文件夹|目录|文件).{0,8}(展现|展示|列出来|看看)|"
r"workDir|工作目录|工程目录",
text or "",
re.I,
))
def list_project_folder(session_id: str | None) -> str:
"""列出当前会话所属项目的登记文件 + workDir 磁盘文件。"""
try:
from server.state.projects import get_project_store
ps = get_project_store()
snap = ps.snapshot(include_messages=False)
except Exception as exc:
return f"项目信息暂时读不到:{exc}"
sessions = {s["id"]: s for s in (snap.get("sessions") or []) if isinstance(s, dict)}
projects = {p["id"]: p for p in (snap.get("projects") or []) if isinstance(p, dict)}
sess = sessions.get(session_id or "") or {}
pid = sess.get("projectId")
proj = projects.get(pid) if pid else None
lines = ["**当前项目里的资料:**", ""]
if not proj:
lines.append("这会儿话题不在某个工程项目下(独立任务)。")
lines.append("你可以先在左侧选一个项目,或把 Excel 用输入框左边 **+** 附上来,我帮你解析。")
else:
lines.append(f"- 项目名称:**{proj.get('name') or '未命名'}**")
work = (proj.get("workDir") or "").strip()
if work:
lines.append(f"- 工程目录:`{work}`")
else:
lines.append("- 工程目录:还没设定(建项目时可选文件夹)")
files = [f for f in (snap.get("files") or [])
if isinstance(f, dict) and f.get("projectId") == pid]
lines.append("")
lines.append("**项目面板已登记的文件:**")
if not files:
lines.append("- (还没有登记文件)")
else:
for f in files[:30]:
lines.append(
f"- {f.get('name')}({f.get('kind') or 'file'})"
+ (f":{f.get('note')}" if f.get("note") else "")
)
if len(files) > 30:
lines.append(f"- ……另有 {len(files) - 30} 个")
if work and os.path.isdir(work):
lines.append("")
lines.append("**工程目录里实际看到的文件:**")
try:
names = sorted(os.listdir(work))
except OSError as exc:
lines.append(f"- 读目录失败:{exc}")
names = []
shown = 0
for name in names:
if name.startswith("."):
continue
path = os.path.join(work, name)
tag = "文件夹" if os.path.isdir(path) else "文件"
size = ""
if os.path.isfile(path):
try:
size = f" · {os.path.getsize(path)} 字节"
except OSError:
size = ""
lines.append(f"- [{tag}] {name}{size}")
shown += 1
if shown >= 40:
lines.append(f"- ……目录里还有更多,我先列前 {shown} 个")
break
if shown == 0:
lines.append("- (目录是空的,或没有可读文件)")
# 顺带提示可解析的表格
tables = [n for n in names if re.search(r"\.(xlsx|xlsm|csv|txt)$", n, re.I)]
if tables:
lines.append("")
lines.append(
f"里面有 **{len(tables)}** 个表格类文件。"
"要我按内容解析的话,用输入框 **+** 附上其中一个,或者说「分析这个文件」。"
)
elif work:
lines.append("")
lines.append(f"工程目录路径我记下了,但本机访问不到:`{work}`(可能没插盘或路径变了)。")
lines.append("")
lines.append(
"如果要看的是**排产主数据**(订单/物料/工艺),跟我说「帮我分析一下当前项目里面的数据」。"
)
return "\n".join(lines)