438 lines
16 KiB
Python
438 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Operator-facing DeepSeek Harness desk.
|
||
|
||
The public programming model follows the Python SDK guide:
|
||
|
||
result = harness.run(prompt, session_id=...)
|
||
|
||
On Linux/macOS, a bundled DeepSeek Harness runtime is started and reused.
|
||
Official runtime wheels are not published for Windows, so this module falls
|
||
back to the same DeepSeek-compatible chat API already configured in
|
||
``ai_config``, still keyed by ``session_id``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from runtime_paths import application_data_dir, resource_path
|
||
|
||
BACKEND_SDK = "sdk"
|
||
BACKEND_COMPAT = "compat"
|
||
INDEX_VERSION = 1
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class RunOutcome:
|
||
session_id: str
|
||
text: str
|
||
backend: str
|
||
finish_reason: str | None = None
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class DeskSession:
|
||
id: str
|
||
title: str
|
||
preview: str
|
||
created_at: float
|
||
updated_at: float
|
||
messages: list[dict[str, Any]]
|
||
|
||
|
||
def desk_root(base: Path | None = None) -> Path:
|
||
return Path(base) if base is not None else application_data_dir() / "dsh_desk"
|
||
|
||
|
||
def desk_system_prompt() -> str:
|
||
try:
|
||
import ai_config
|
||
|
||
name = str(getattr(ai_config, "AI_AGENT_NAME", "") or "客服").strip() or "客服"
|
||
hospital = str(getattr(ai_config, "AI_HOSPITAL_NAME", "") or "").strip()
|
||
except Exception:
|
||
name = "客服"
|
||
hospital = ""
|
||
hospital_line = f"对外医院名称是「{hospital}」。" if hospital else ""
|
||
return (
|
||
"你是甄养堂客服工作台里的 DeepSeek Agent,协助坐席处理客户咨询、"
|
||
"起草回复和整理沟通思路。\n"
|
||
f"坐席当前对外身份是「{name}」。{hospital_line}\n"
|
||
"- 直接回答坐席的问题;需要给客户看的草稿,用「"
|
||
f"{name}」的真人口吻写,不要出现 AI、机器人、模型等字样。\n"
|
||
"- 不要编造检查结果、单号、物流、挂号或尚未发生的事实。\n"
|
||
"- 缺信息时明确说还缺什么,而不是猜。"
|
||
)
|
||
|
||
|
||
def _now() -> float:
|
||
return time.time()
|
||
|
||
|
||
def _title_from_prompt(prompt: str) -> str:
|
||
text = " ".join(str(prompt or "").split())
|
||
if not text:
|
||
return "新对话"
|
||
return text if len(text) <= 18 else text[:17] + "…"
|
||
|
||
|
||
class DeepSeekDesk:
|
||
"""Reusable desk: one harness (or compatible client) across many sessions."""
|
||
|
||
def __init__(self, root: Path | None = None) -> None:
|
||
self.root = desk_root(root)
|
||
self.workspace = self.root / "workspace"
|
||
self.session_root = self.root / "sessions"
|
||
self.transcript_dir = self.root / "transcripts"
|
||
self.index_path = self.root / "index.json"
|
||
self._lock = threading.RLock()
|
||
self._backend: str | None = None
|
||
self._backend_error = ""
|
||
self._harness: Any = None
|
||
|
||
def close(self) -> None:
|
||
with self._lock:
|
||
harness = self._harness
|
||
self._harness = None
|
||
if harness is not None:
|
||
try:
|
||
harness.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def backend_name(self) -> str:
|
||
if self._backend == BACKEND_SDK:
|
||
return BACKEND_SDK
|
||
if self._backend == BACKEND_COMPAT:
|
||
return BACKEND_COMPAT
|
||
return BACKEND_SDK if _sdk_module_available() else BACKEND_COMPAT
|
||
|
||
def backend_label(self) -> str:
|
||
if self._backend == BACKEND_SDK:
|
||
return "DeepSeek Harness Python SDK"
|
||
if self._backend == BACKEND_COMPAT:
|
||
if self._backend_error:
|
||
return f"DeepSeek 兼容接口(SDK 运行时不可用:{self._backend_error})"
|
||
return "DeepSeek 兼容接口"
|
||
if _sdk_module_available():
|
||
return "DeepSeek Harness Python SDK(首次对话时启动运行时)"
|
||
return "DeepSeek 兼容接口(Windows 无官方 SDK 运行时,会话 API 与指南一致)"
|
||
|
||
def list_sessions(self) -> list[DeskSession]:
|
||
with self._lock:
|
||
index = self._load_index()
|
||
sessions = [self._read_session(item["id"]) for item in index.get("sessions") or []]
|
||
return [item for item in sessions if item is not None]
|
||
|
||
def get_session(self, session_id: str) -> DeskSession | None:
|
||
with self._lock:
|
||
return self._read_session(session_id)
|
||
|
||
def create_session(self) -> DeskSession:
|
||
with self._lock:
|
||
session = DeskSession(
|
||
id=f"desk-{uuid.uuid4().hex}",
|
||
title="新对话",
|
||
preview="",
|
||
created_at=_now(),
|
||
updated_at=_now(),
|
||
messages=[],
|
||
)
|
||
self._write_session(session)
|
||
self._upsert_index(session, current=True)
|
||
return session
|
||
|
||
def run(self, prompt: str, *, session_id: str | None = None) -> RunOutcome:
|
||
text = str(prompt or "").strip()
|
||
if not text:
|
||
raise ValueError("请先输入要交给 Agent 的任务")
|
||
with self._lock:
|
||
session = self._read_session(session_id) if session_id else None
|
||
if session is None:
|
||
if session_id:
|
||
session = DeskSession(
|
||
id=session_id,
|
||
title="新对话",
|
||
preview="",
|
||
created_at=_now(),
|
||
updated_at=_now(),
|
||
messages=[],
|
||
)
|
||
self._write_session(session)
|
||
self._upsert_index(session, current=True)
|
||
else:
|
||
session = self.create_session()
|
||
session.messages.append(
|
||
{"role": "user", "content": text, "ts": _now()}
|
||
)
|
||
if session.title == "新对话":
|
||
session.title = _title_from_prompt(text)
|
||
session.preview = text
|
||
session.updated_at = _now()
|
||
self._write_session(session)
|
||
self._upsert_index(session, current=True)
|
||
active_id = session.id
|
||
outcome = self._run_model(text, active_id)
|
||
reply = str(outcome.text or "").strip()
|
||
with self._lock:
|
||
session = self._read_session(active_id) or session
|
||
session.messages.append(
|
||
{
|
||
"role": "assistant",
|
||
"content": reply or "(Agent 没有返回文本)",
|
||
"ts": _now(),
|
||
"backend": outcome.backend,
|
||
}
|
||
)
|
||
session.preview = reply or session.preview
|
||
session.updated_at = _now()
|
||
self._write_session(session)
|
||
self._upsert_index(session, current=True)
|
||
return RunOutcome(
|
||
session_id=active_id,
|
||
text=reply,
|
||
backend=outcome.backend,
|
||
finish_reason=outcome.finish_reason,
|
||
)
|
||
|
||
def current_session_id(self) -> str:
|
||
with self._lock:
|
||
index = self._load_index()
|
||
current = str(index.get("current_id") or "")
|
||
if current and self._read_session(current) is not None:
|
||
return current
|
||
sessions = index.get("sessions") or []
|
||
if sessions:
|
||
return str(sessions[0]["id"])
|
||
return self.create_session().id
|
||
|
||
def set_current_session(self, session_id: str) -> DeskSession | None:
|
||
with self._lock:
|
||
session = self._read_session(session_id)
|
||
if session is None:
|
||
return None
|
||
self._upsert_index(session, current=True)
|
||
return session
|
||
|
||
def _ensure_backend(self) -> None:
|
||
with self._lock:
|
||
if self._backend is not None:
|
||
return
|
||
try:
|
||
self._harness = self._start_harness()
|
||
self._backend = BACKEND_SDK
|
||
self._backend_error = ""
|
||
except Exception as exc:
|
||
self._harness = None
|
||
self._backend = BACKEND_COMPAT
|
||
self._backend_error = _short_error(exc)
|
||
|
||
def _start_harness(self) -> Any:
|
||
from deepseek_harness import DeepSeekHarness
|
||
|
||
self.workspace.mkdir(parents=True, exist_ok=True)
|
||
self.session_root.mkdir(parents=True, exist_ok=True)
|
||
model, api_key, base_url, timeout = _model_settings()
|
||
if not api_key:
|
||
raise RuntimeError("未配置 DeepSeek API Key")
|
||
cordis = resource_path("dsh_desk.cordis.yml")
|
||
if not cordis.is_file():
|
||
cordis = Path(__file__).resolve().parent / "dsh_desk.cordis.yml"
|
||
env = {
|
||
"DSH_SYSTEM_PROMPT": desk_system_prompt(),
|
||
"DSH_MODEL": model,
|
||
}
|
||
harness = DeepSeekHarness(
|
||
provider="deepseek-official",
|
||
model=model,
|
||
cwd=str(self.workspace.resolve()),
|
||
session_root=str(self.session_root.resolve()),
|
||
cordis=str(cordis.resolve()) if cordis.is_file() else None,
|
||
api_key=api_key,
|
||
base_url=base_url or None,
|
||
env=env,
|
||
request_timeout_seconds=timeout,
|
||
)
|
||
harness.start()
|
||
return harness
|
||
|
||
def _run_model(self, prompt: str, session_id: str) -> RunOutcome:
|
||
self._ensure_backend()
|
||
if self._backend == BACKEND_SDK and self._harness is not None:
|
||
result = self._harness.run(prompt, session_id=session_id)
|
||
return RunOutcome(
|
||
session_id=session_id,
|
||
text=str(getattr(result, "final_response", "") or ""),
|
||
backend=BACKEND_SDK,
|
||
finish_reason=getattr(result, "finish_reason", None),
|
||
)
|
||
return RunOutcome(
|
||
session_id=session_id,
|
||
text=self._run_compat(prompt, session_id),
|
||
backend=BACKEND_COMPAT,
|
||
finish_reason="completed",
|
||
)
|
||
|
||
def _run_compat(self, prompt: str, session_id: str) -> str:
|
||
import ai_chat
|
||
|
||
session = self._read_session(session_id)
|
||
history = []
|
||
if session is not None:
|
||
for item in session.messages[:-1]:
|
||
role = str(item.get("role") or "")
|
||
content = str(item.get("content") or "").strip()
|
||
if role in {"user", "assistant"} and content:
|
||
history.append({"role": role, "content": content})
|
||
try:
|
||
import ai_config
|
||
|
||
max_rounds = int(getattr(ai_config, "AI_CONTEXT_MAX_ROUNDS", 8) or 8)
|
||
except Exception:
|
||
max_rounds = 8
|
||
if max_rounds > 0:
|
||
history = history[-(max_rounds * 2) :]
|
||
messages = [{"role": "system", "content": desk_system_prompt()}]
|
||
messages.extend(history)
|
||
messages.append({"role": "user", "content": prompt})
|
||
msg = ai_chat._chat_completion(messages)
|
||
return str(msg.get("content") or "").strip()
|
||
|
||
def _load_index(self) -> dict[str, Any]:
|
||
self.root.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
data = json.loads(self.index_path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError, TypeError):
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
data.setdefault("version", INDEX_VERSION)
|
||
data.setdefault("sessions", [])
|
||
data.setdefault("current_id", "")
|
||
return data
|
||
|
||
def _upsert_index(self, session: DeskSession, *, current: bool) -> None:
|
||
index = self._load_index()
|
||
items = [
|
||
item
|
||
for item in index.get("sessions") or []
|
||
if isinstance(item, dict) and item.get("id") != session.id
|
||
]
|
||
items.insert(
|
||
0,
|
||
{
|
||
"id": session.id,
|
||
"title": session.title,
|
||
"preview": session.preview,
|
||
"updated_at": session.updated_at,
|
||
},
|
||
)
|
||
items.sort(key=lambda item: float(item.get("updated_at") or 0), reverse=True)
|
||
index["sessions"] = items[:80]
|
||
if current:
|
||
index["current_id"] = session.id
|
||
self._atomic_write(self.index_path, index)
|
||
|
||
def _transcript_path(self, session_id: str) -> Path:
|
||
self.transcript_dir.mkdir(parents=True, exist_ok=True)
|
||
return self.transcript_dir / f"{session_id}.json"
|
||
|
||
def _read_session(self, session_id: str | None) -> DeskSession | None:
|
||
if not session_id:
|
||
return None
|
||
path = self._transcript_path(session_id)
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError, TypeError):
|
||
return None
|
||
if not isinstance(data, dict):
|
||
return None
|
||
messages = data.get("messages") if isinstance(data.get("messages"), list) else []
|
||
return DeskSession(
|
||
id=str(data.get("id") or session_id),
|
||
title=str(data.get("title") or "新对话"),
|
||
preview=str(data.get("preview") or ""),
|
||
created_at=float(data.get("created_at") or _now()),
|
||
updated_at=float(data.get("updated_at") or _now()),
|
||
messages=[item for item in messages if isinstance(item, dict)],
|
||
)
|
||
|
||
def _write_session(self, session: DeskSession) -> None:
|
||
payload = {
|
||
"id": session.id,
|
||
"title": session.title,
|
||
"preview": session.preview,
|
||
"created_at": session.created_at,
|
||
"updated_at": session.updated_at,
|
||
"messages": session.messages,
|
||
}
|
||
self._atomic_write(self._transcript_path(session.id), payload)
|
||
|
||
@staticmethod
|
||
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||
temporary.write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
temporary.replace(path)
|
||
|
||
|
||
_MODEL_DEFAULT = "deepseek-chat"
|
||
_desk: DeepSeekDesk | None = None
|
||
_desk_lock = threading.Lock()
|
||
|
||
|
||
def _model_settings() -> tuple[str, str, str, float]:
|
||
try:
|
||
import ai_config
|
||
|
||
model = str(getattr(ai_config, "AI_MODEL", "") or "").strip() or _MODEL_DEFAULT
|
||
api_key = str(getattr(ai_config, "AI_API_KEY", "") or "").strip()
|
||
base_url = str(getattr(ai_config, "AI_API_BASE", "") or "").strip()
|
||
timeout = float(getattr(ai_config, "AI_TIMEOUT", 120) or 120)
|
||
except Exception:
|
||
model, api_key, base_url, timeout = _MODEL_DEFAULT, "", "", 120.0
|
||
return model, api_key, base_url, max(30.0, timeout)
|
||
|
||
|
||
def _sdk_module_available() -> bool:
|
||
try:
|
||
import deepseek_harness # noqa: F401
|
||
except Exception:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _short_error(exc: BaseException) -> str:
|
||
text = str(exc or exc.__class__.__name__).strip().replace("\n", " ")
|
||
if "No matching distribution" in text or "deepseek-harness-runtime" in text:
|
||
return "当前系统没有官方 Windows 运行时"
|
||
if "FileNotFoundError" in type(exc).__name__ or "Unable to locate" in text:
|
||
return "未找到 SDK 运行时"
|
||
return text[:80] or exc.__class__.__name__
|
||
|
||
|
||
def get_desk() -> DeepSeekDesk:
|
||
global _desk
|
||
with _desk_lock:
|
||
if _desk is None:
|
||
_desk = DeepSeekDesk()
|
||
return _desk
|
||
|
||
|
||
def close_desk() -> None:
|
||
global _desk
|
||
with _desk_lock:
|
||
desk = _desk
|
||
_desk = None
|
||
if desk is not None:
|
||
desk.close()
|