aps-agent/server/agent_core/context.py

230 lines
8.8 KiB
Python
Raw 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.

# ============================================================
# 多轮对话上下文(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)