@tencent-rtc/trtc-agent-skills 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/CODEBUDDY.md +1 -1
- package/README.md +6 -73
- package/README.zh.md +6 -69
- package/bin/cli.js +9 -7
- package/package.json +1 -1
- package/skills/trtc/SKILL.md +55 -4
- package/skills/trtc-ai-oral-coach/README.ja.md +131 -0
- package/skills/trtc-ai-oral-coach/README.md +132 -0
- package/skills/trtc-ai-oral-coach/README.zh-CN.md +131 -0
- package/skills/trtc-ai-oral-coach/SKILL.md +395 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/integration_templates/custom-learning-kb-sample.md +88 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/integration_templates/generic-integration.md +104 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/manifest.yaml +27 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/python/README.md +49 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/python/coach_client.py.tpl +185 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/web/README.md +46 -0
- package/skills/trtc-ai-oral-coach/auto_adapters/web/oral-coach-client.js.tpl +210 -0
- package/skills/trtc-ai-oral-coach/capabilities/ability-report/manifest.yaml +52 -0
- package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/adapters/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/adapters/default.py +177 -0
- package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/handler.py +29 -0
- package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/router.py +23 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/.env.example +59 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/manifest.yaml +46 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/requirements.txt +6 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/TLSSigAPIv2.py +275 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/_capability_loader.py +227 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/agent.py +197 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/config.py +149 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/core/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/core/report_llm.py +129 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/health.py +36 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/server.py +205 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/trtc_client.py +86 -0
- package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/usersig.py +33 -0
- package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/manifest.yaml +65 -0
- package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/adapters/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/adapters/clients.py +126 -0
- package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/ports.py +46 -0
- package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/router.py +27 -0
- package/skills/trtc-ai-oral-coach/capabilities/quick-correct/manifest.yaml +48 -0
- package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/adapters/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/adapters/default.py +106 -0
- package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/handler.py +33 -0
- package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/router.py +23 -0
- package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/manifest.yaml +49 -0
- package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/adapters/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/adapters/default.py +86 -0
- package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/handler.py +29 -0
- package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/router.py +23 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/data/practice-scenarios.json +500 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/manifest.yaml +54 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/adapters/__init__.py +2 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/adapters/default.py +113 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/compose.py +143 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/defaults.py +140 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/handler.py +29 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/router.py +22 -0
- package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/scenario_source.py +179 -0
- package/skills/trtc-ai-oral-coach/references/design-specs.md +36 -0
- package/skills/trtc-ai-oral-coach/references/evaluator-port.md +75 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/recipe.yaml +86 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/avatars/friend.png +0 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/avatars/listener.png +0 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/avatars/local.png +0 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/coach.html +1986 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/i18n.js +323 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/practice-scenarios.json +500 -0
- package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/tokens.css +100 -0
- package/skills/trtc-ai-oral-coach/scripts/add-capability.py +256 -0
- package/skills/trtc-ai-oral-coach/scripts/verify-credentials.py +96 -0
- package/skills/trtc-ai-oral-coach/start.sh +59 -0
- package/skills/trtc-ai-oral-coach/triggers.yaml +32 -0
- package/skills/trtc-ai-service/README.ja.md +15 -32
- package/skills/trtc-ai-service/README.md +15 -32
- package/skills/trtc-ai-service/README.zh-CN.md +15 -32
- package/skills/trtc-ai-service/SKILL.md +188 -120
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Dynamic loader for sibling capability packages (independent of cwd / repo directory name / hyphens).
|
|
2
|
+
|
|
3
|
+
Why this module?
|
|
4
|
+
================
|
|
5
|
+
Capability directories use hyphenated names (e.g. ``knowledge-base``, ``human-handoff``),
|
|
6
|
+
but Python ``import`` syntax cannot recognize hyphens. Additionally, the ``start.sh``
|
|
7
|
+
process working directory is ``capabilities/conversation-core/``, so the project root
|
|
8
|
+
is NOT in ``sys.path``. Therefore, the import style in manifest.yaml:
|
|
9
|
+
|
|
10
|
+
from capabilities.knowledge_base.src.retriever import attach_faq_to_instructions
|
|
11
|
+
|
|
12
|
+
will **never work** — it implicitly assumes:
|
|
13
|
+
1. Directory names use underscores (they actually use hyphens);
|
|
14
|
+
2. The project root is in ``sys.path`` (it is not).
|
|
15
|
+
|
|
16
|
+
This module uses ``importlib.util`` to proactively register each directory level as a
|
|
17
|
+
valid Python package, bypassing package name restrictions; the project root is derived
|
|
18
|
+
from ``__file__``, so **renaming the repo directory has no effect**. Relative imports
|
|
19
|
+
such as ``from .x import y`` inside sub-modules also work correctly.
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
from ._capability_loader import load_capability
|
|
24
|
+
|
|
25
|
+
retriever = load_capability("knowledge-base", "src/retriever.py")
|
|
26
|
+
new_text = retriever.attach_faq_to_instructions(text)
|
|
27
|
+
|
|
28
|
+
router_mod = load_capability("knowledge-base", "src/router.py")
|
|
29
|
+
app.include_router(router_mod.router, prefix="/api/v1/kb")
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import importlib.util
|
|
34
|
+
import logging
|
|
35
|
+
import sys
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from threading import RLock
|
|
38
|
+
from types import ModuleType
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Path resolution: derive repo_root from __file__, independent of cwd / repo directory name
|
|
45
|
+
# This file is at <repo_root>/capabilities/conversation-core/src/_capability_loader.py
|
|
46
|
+
# parents[3] = <repo_root>
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
_HERE = Path(__file__).resolve()
|
|
49
|
+
_REPO_ROOT = _HERE.parents[3]
|
|
50
|
+
_CAPABILITIES_ROOT = _REPO_ROOT / "capabilities"
|
|
51
|
+
|
|
52
|
+
_CAPS_NAMESPACE = "_capabilities"
|
|
53
|
+
|
|
54
|
+
_lock = RLock()
|
|
55
|
+
_module_cache: dict[str, ModuleType] = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def repo_root() -> Path:
|
|
59
|
+
"""Return the repo root directory (the level containing ``capabilities/``)."""
|
|
60
|
+
return _REPO_ROOT
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def capabilities_root() -> Path:
|
|
64
|
+
return _CAPABILITIES_ROOT
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_name(part: str) -> str:
|
|
68
|
+
"""Convert a directory segment to a valid Python identifier (hyphens → underscores)."""
|
|
69
|
+
return part.replace("-", "_")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ensure_namespace_root() -> ModuleType:
|
|
73
|
+
"""Register the ``_capabilities`` top-level namespace package in ``sys.modules``."""
|
|
74
|
+
mod = sys.modules.get(_CAPS_NAMESPACE)
|
|
75
|
+
if mod is not None:
|
|
76
|
+
return mod
|
|
77
|
+
spec = importlib.util.spec_from_loader(_CAPS_NAMESPACE, loader=None, is_package=True)
|
|
78
|
+
if spec is None:
|
|
79
|
+
raise RuntimeError("failed to build namespace spec")
|
|
80
|
+
mod = importlib.util.module_from_spec(spec)
|
|
81
|
+
mod.__path__ = [str(_CAPABILITIES_ROOT)] # Let importlib find sub-packages under this directory
|
|
82
|
+
sys.modules[_CAPS_NAMESPACE] = mod
|
|
83
|
+
return mod
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ensure_package(qualified_name: str, dir_path: Path) -> ModuleType:
|
|
87
|
+
"""Register ``dir_path`` as a Python package named ``qualified_name``.
|
|
88
|
+
|
|
89
|
+
If a ``__init__.py`` with the same name exists, exec it normally; otherwise treat as a namespace package.
|
|
90
|
+
Idempotent: if already in ``sys.modules``, returns immediately.
|
|
91
|
+
"""
|
|
92
|
+
cached = sys.modules.get(qualified_name)
|
|
93
|
+
if cached is not None:
|
|
94
|
+
return cached
|
|
95
|
+
|
|
96
|
+
init_file = dir_path / "__init__.py"
|
|
97
|
+
if init_file.is_file():
|
|
98
|
+
spec = importlib.util.spec_from_file_location(
|
|
99
|
+
qualified_name,
|
|
100
|
+
init_file,
|
|
101
|
+
submodule_search_locations=[str(dir_path)],
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
spec = importlib.util.spec_from_loader(qualified_name, loader=None, is_package=True)
|
|
105
|
+
if spec is None:
|
|
106
|
+
raise ModuleNotFoundError(f"failed to build spec for package: {qualified_name}")
|
|
107
|
+
|
|
108
|
+
pkg = importlib.util.module_from_spec(spec)
|
|
109
|
+
if not hasattr(pkg, "__path__"):
|
|
110
|
+
pkg.__path__ = [str(dir_path)] # type: ignore[attr-defined]
|
|
111
|
+
sys.modules[qualified_name] = pkg
|
|
112
|
+
|
|
113
|
+
if init_file.is_file() and spec.loader is not None:
|
|
114
|
+
try:
|
|
115
|
+
spec.loader.exec_module(pkg)
|
|
116
|
+
except Exception:
|
|
117
|
+
sys.modules.pop(qualified_name, None)
|
|
118
|
+
raise
|
|
119
|
+
return pkg
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_capability(cap_name: str, module_rel: str) -> ModuleType:
|
|
123
|
+
"""Load a Python file under a given capability package and return its module object.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
cap_name
|
|
128
|
+
Capability directory name, e.g. ``"knowledge-base"`` (with hyphens).
|
|
129
|
+
module_rel
|
|
130
|
+
Python file path relative to the capability root, e.g. ``"src/retriever.py"``.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
ModuleType
|
|
135
|
+
The executed module object. Raises :class:`ModuleNotFoundError` on failure.
|
|
136
|
+
|
|
137
|
+
Notes
|
|
138
|
+
-----
|
|
139
|
+
- In-process cache: the same ``(cap_name, module_rel)`` is loaded only once.
|
|
140
|
+
- Full module name is e.g. ``_capabilities.<cap_safe>.<dir>.<basename>``,
|
|
141
|
+
so relative imports like ``from .x import y`` inside capabilities work correctly.
|
|
142
|
+
"""
|
|
143
|
+
cache_key = f"{cap_name}::{module_rel}"
|
|
144
|
+
with _lock:
|
|
145
|
+
cached = _module_cache.get(cache_key)
|
|
146
|
+
if cached is not None:
|
|
147
|
+
return cached
|
|
148
|
+
|
|
149
|
+
cap_dir = _CAPABILITIES_ROOT / cap_name
|
|
150
|
+
file_path = cap_dir / module_rel
|
|
151
|
+
if not file_path.is_file():
|
|
152
|
+
raise ModuleNotFoundError(
|
|
153
|
+
f"capability '{cap_name}' module '{module_rel}' not found at {file_path}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# 1) Top-level namespace _capabilities.*
|
|
157
|
+
_ensure_namespace_root()
|
|
158
|
+
|
|
159
|
+
# 2) Capability package name _capabilities.<cap_safe>
|
|
160
|
+
cap_safe = _safe_name(cap_name)
|
|
161
|
+
cap_qual = f"{_CAPS_NAMESPACE}.{cap_safe}"
|
|
162
|
+
_ensure_package(cap_qual, cap_dir)
|
|
163
|
+
|
|
164
|
+
# 3) Register each intermediate directory level as a sub-package
|
|
165
|
+
rel_parts = Path(module_rel).parts
|
|
166
|
+
*dir_parts, leaf = rel_parts
|
|
167
|
+
parent_qual = cap_qual
|
|
168
|
+
parent_dir = cap_dir
|
|
169
|
+
for part in dir_parts:
|
|
170
|
+
parent_dir = parent_dir / part
|
|
171
|
+
parent_qual = f"{parent_qual}.{_safe_name(part)}"
|
|
172
|
+
_ensure_package(parent_qual, parent_dir)
|
|
173
|
+
|
|
174
|
+
# 4) Load leaf module
|
|
175
|
+
leaf_basename = Path(leaf).stem
|
|
176
|
+
leaf_qual = f"{parent_qual}.{_safe_name(leaf_basename)}"
|
|
177
|
+
|
|
178
|
+
cached_leaf = sys.modules.get(leaf_qual)
|
|
179
|
+
if cached_leaf is not None:
|
|
180
|
+
with _lock:
|
|
181
|
+
_module_cache[cache_key] = cached_leaf
|
|
182
|
+
return cached_leaf
|
|
183
|
+
|
|
184
|
+
spec = importlib.util.spec_from_file_location(leaf_qual, file_path)
|
|
185
|
+
if spec is None or spec.loader is None:
|
|
186
|
+
raise ModuleNotFoundError(
|
|
187
|
+
f"failed to build spec for capability '{cap_name}' / '{module_rel}'"
|
|
188
|
+
)
|
|
189
|
+
module = importlib.util.module_from_spec(spec)
|
|
190
|
+
sys.modules[leaf_qual] = module
|
|
191
|
+
try:
|
|
192
|
+
spec.loader.exec_module(module)
|
|
193
|
+
except Exception:
|
|
194
|
+
sys.modules.pop(leaf_qual, None)
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
with _lock:
|
|
198
|
+
_module_cache[cache_key] = module
|
|
199
|
+
logger.debug("capability loaded: %s -> %s", leaf_qual, file_path)
|
|
200
|
+
return module
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def try_load_capability(
|
|
204
|
+
cap_name: str, module_rel: str
|
|
205
|
+
) -> Optional[ModuleType]:
|
|
206
|
+
"""Same as :func:`load_capability`, but returns ``None`` on failure instead of raising.
|
|
207
|
+
|
|
208
|
+
Suitable for "capability is optionally installed" scenarios: silently degrades
|
|
209
|
+
on missing, without affecting skeleton operation.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
return load_capability(cap_name, module_rel)
|
|
213
|
+
except Exception as exc: # noqa: BLE001
|
|
214
|
+
logger.info(
|
|
215
|
+
"capability '%s' module '%s' not loaded (skipped): %s",
|
|
216
|
+
cap_name, module_rel, exc,
|
|
217
|
+
)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# 全局模块别名:让任意能力都能 `import cap_loader` 拿到与 core 同一份 loader,
|
|
223
|
+
# 从而实现"能力互相调用"(例如 scenario-roleplay 按需调用 custom-learning-kb),
|
|
224
|
+
# 而不必知道 conversation-core 的包路径。用法同 core/report_llm.py 的
|
|
225
|
+
# `coach_report_llm` 别名模式。
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
sys.modules.setdefault("cap_loader", sys.modules[__name__])
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""会话编排 —— 忠实移植定稿 Demo 的 join / start / stop / farewell / invoke 逻辑。
|
|
3
|
+
|
|
4
|
+
设计:
|
|
5
|
+
* core 只做协议编排 + TRTC 调用;**实时对话零 LLM**。
|
|
6
|
+
* system_prompt / welcome / 语速由 scenario-roleplay 能力组装(通过 _capability_loader
|
|
7
|
+
动态加载 src/compose.py;未安装则用极简默认,保证光核心也能进房对话)。
|
|
8
|
+
* push-to-talk(InvokeLLM):用户点"结束说话"后手动触发 AI 回复。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from ._capability_loader import try_load_capability
|
|
16
|
+
from .config import CoreConfig
|
|
17
|
+
from .trtc_client import TRTCClient
|
|
18
|
+
from .usersig import sign_trio, gen_user_sig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("agent")
|
|
21
|
+
|
|
22
|
+
# ---- TRTC 协议级常量(与定稿 Demo 一致)----
|
|
23
|
+
TTS_VOICE_MAP = {
|
|
24
|
+
("en", "female"): "v-female-p9Xy7Q1L",
|
|
25
|
+
("en", "male"): "v-male-A4b9KqP2",
|
|
26
|
+
("zh", "female"): "female-kefu-xiaoyue",
|
|
27
|
+
("zh", "male"): "male-kefu-xiaoxu",
|
|
28
|
+
}
|
|
29
|
+
_EN_HOTWORDS = ",".join([
|
|
30
|
+
"zero|11","one|11","two|11","three|11","four|11","five|11","six|11","seven|11",
|
|
31
|
+
"eight|11","nine|11","ten|11","eleven|11","twelve|11",
|
|
32
|
+
"first|10","second|10","third|10","fourth|10","fifth|10","once|10","twice|10",
|
|
33
|
+
"um|11","uh|11","oh|11","hmm|11","ah|11","er|11","yeah|11","yep|11","nope|11",
|
|
34
|
+
"okay|11","ok|11","like|10","well|10","so|10","right|10",
|
|
35
|
+
])
|
|
36
|
+
SCENARIOS = {"travel", "work", "study", "free"}
|
|
37
|
+
LEVELS = {"beginner", "intermediate", "advanced"}
|
|
38
|
+
STYLES = {"friend", "listener", "local"}
|
|
39
|
+
GENDERS = {"female", "male"}
|
|
40
|
+
|
|
41
|
+
DEFAULT_WELCOME = "Hi! I'm your AI English speaking partner. Ready to start? Let's warm up..."
|
|
42
|
+
DEFAULT_FAREWELL = {
|
|
43
|
+
"en": "Great chat today! Let me put together your report...",
|
|
44
|
+
"zh": "今天聊得不错!我来为你生成报告...",
|
|
45
|
+
}
|
|
46
|
+
DEFAULT_END_KEYWORDS = {
|
|
47
|
+
"en": ["end practice", "stop practice", "i'm done", "that's enough", "finish session"],
|
|
48
|
+
"zh": ["结束练习", "我练完了", "停止练习", "不练了"],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _voice_id(lang: str, gender: str) -> str:
|
|
53
|
+
key = (lang if lang in ("en", "zh") else "en",
|
|
54
|
+
gender if gender in GENDERS else "female")
|
|
55
|
+
return TTS_VOICE_MAP[key]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Agent:
|
|
59
|
+
def __init__(self, cfg: CoreConfig) -> None:
|
|
60
|
+
self._cfg = cfg
|
|
61
|
+
self._trtc = TRTCClient(cfg)
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# join / config —— 签发三套 UserSig + 下发前端配置
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
def issue_config(self, user_id: str) -> Dict[str, Any]:
|
|
67
|
+
if not user_id:
|
|
68
|
+
raise ValueError("userid is required")
|
|
69
|
+
sigs = sign_trio(self._cfg.trtc.sdk_app_id, self._cfg.trtc.sdk_secret_key, user_id)
|
|
70
|
+
avatar_on = self._cfg.avatar_enabled()
|
|
71
|
+
|
|
72
|
+
# 风格元数据由 scenario-roleplay 提供(未装 → 空列表)
|
|
73
|
+
styles = []
|
|
74
|
+
_sr = try_load_capability("scenario-roleplay", "src/compose.py")
|
|
75
|
+
if _sr is not None and hasattr(_sr, "styles_metadata"):
|
|
76
|
+
try:
|
|
77
|
+
styles = _sr.styles_metadata()
|
|
78
|
+
except Exception as exc: # noqa: BLE001
|
|
79
|
+
logger.warning("styles_metadata failed: %s", exc)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"sdkappid": sigs["sdkappid"],
|
|
83
|
+
"userid": sigs["user_id"],
|
|
84
|
+
"usersig": sigs["user_sig"],
|
|
85
|
+
"robot_userid": sigs["robot_user_id"],
|
|
86
|
+
"robot_usersig": sigs["robot_user_sig"],
|
|
87
|
+
"avatar_userid": sigs["avatar_user_id"] if avatar_on else "",
|
|
88
|
+
"avatar_usersig": sigs["avatar_user_sig"] if avatar_on else "",
|
|
89
|
+
"avatar_available": avatar_on,
|
|
90
|
+
"styles": styles,
|
|
91
|
+
"personas": styles, # 兼容别名
|
|
92
|
+
"end_keywords": DEFAULT_END_KEYWORDS,
|
|
93
|
+
"farewell_message": DEFAULT_FAREWELL,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# start —— 组装 prompt(scenario-roleplay)+ 调 TRTC StartAIConversation
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
def start(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
100
|
+
use_avatar = bool(body.get("UseAvatar", False)) and self._cfg.avatar_enabled()
|
|
101
|
+
|
|
102
|
+
# === agent.compose_conversation 插座:scenario-roleplay 组装 ===
|
|
103
|
+
prep = self._compose(body)
|
|
104
|
+
system_prompt = prep["system_prompt"]
|
|
105
|
+
welcome = prep["welcome"]
|
|
106
|
+
speed = float(prep.get("speed", 1.0))
|
|
107
|
+
gender = prep.get("voice_gender", "female")
|
|
108
|
+
|
|
109
|
+
user_cfg = body.get("UserConfig", {}) or {}
|
|
110
|
+
interrupt_mode = int(user_cfg.get("InterruptMode", 1))
|
|
111
|
+
interrupt_dur = int(user_cfg.get("InterruptSpeechDuration", 600))
|
|
112
|
+
vad_level = int(user_cfg.get("VadLevel", 2))
|
|
113
|
+
vad_silence = int(user_cfg.get("VadSilenceTime", 1200))
|
|
114
|
+
turn_mode = int(user_cfg.get("TurnDetectionMode", 3))
|
|
115
|
+
|
|
116
|
+
agent_cfg = {
|
|
117
|
+
"UserId": body["AgentConfig"]["UserId"],
|
|
118
|
+
"UserSig": body["AgentConfig"]["UserSig"],
|
|
119
|
+
"TargetUserId": body["AgentConfig"]["TargetUserId"],
|
|
120
|
+
"MaxIdleTime": 60,
|
|
121
|
+
"WelcomeMessage": welcome,
|
|
122
|
+
"WelcomeMessagePriority": 0,
|
|
123
|
+
"TurnDetectionMode": turn_mode,
|
|
124
|
+
"TurnDetection": {"SemanticEagerness": "auto"},
|
|
125
|
+
"FilterOneWord": False,
|
|
126
|
+
"FilterBracketsContent": 4, # 过滤 [FEEDBACK]/[FOLLOWUP] 标签不进 TTS/字幕
|
|
127
|
+
"SubtitleMode": 0, # 全量累积字幕(与前端 isAccumulative 匹配)
|
|
128
|
+
"InterruptMode": interrupt_mode,
|
|
129
|
+
"InterruptSpeechDuration": interrupt_dur,
|
|
130
|
+
}
|
|
131
|
+
stt_cfg = {"Language": "en", "VadLevel": vad_level, "VadSilenceTime": vad_silence,
|
|
132
|
+
"HotWordList": _EN_HOTWORDS}
|
|
133
|
+
|
|
134
|
+
import json as _json
|
|
135
|
+
params = {
|
|
136
|
+
"SdkAppId": self._cfg.trtc.sdk_app_id,
|
|
137
|
+
"RoomId": str(body["RoomId"]),
|
|
138
|
+
"RoomIdType": 0,
|
|
139
|
+
"AgentConfig": agent_cfg,
|
|
140
|
+
"STTConfig": stt_cfg,
|
|
141
|
+
"LLMConfig": _json.dumps(self._cfg.llm_config_with_prompt(system_prompt), ensure_ascii=False),
|
|
142
|
+
}
|
|
143
|
+
if use_avatar:
|
|
144
|
+
params["TTSConfig"] = _json.dumps({"TTSType": "dummy"}, ensure_ascii=False)
|
|
145
|
+
avatar = self._cfg.avatar_config()
|
|
146
|
+
params["AvatarConfig"] = _json.dumps({
|
|
147
|
+
**avatar,
|
|
148
|
+
"AvatarUserID": body["AvatarConfig"]["AvatarUserID"],
|
|
149
|
+
"DriverType": 1,
|
|
150
|
+
"AvatarUserSig": body["AvatarConfig"]["AvatarUserSig"],
|
|
151
|
+
}, ensure_ascii=False)
|
|
152
|
+
else:
|
|
153
|
+
params["TTSConfig"] = _json.dumps({
|
|
154
|
+
"TTSType": "flow", "Model": "flow_01_turbo",
|
|
155
|
+
"VoiceId": _voice_id("en", gender), "Language": "en",
|
|
156
|
+
"Speed": max(0.5, min(2.0, speed)), "Volume": 1.0, "Pitch": 0,
|
|
157
|
+
}, ensure_ascii=False)
|
|
158
|
+
|
|
159
|
+
result = self._trtc.start(params)
|
|
160
|
+
logger.info("StartAIConversation ok: TaskId=%s welcome_source=%s",
|
|
161
|
+
result.get("TaskId"), prep.get("welcome_source"))
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
def _compose(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
165
|
+
"""调 scenario-roleplay 组装;未安装则极简默认(保证光核心可对话)。"""
|
|
166
|
+
_sr = try_load_capability("scenario-roleplay", "src/compose.py")
|
|
167
|
+
if _sr is not None and hasattr(_sr, "compose_prepare"):
|
|
168
|
+
try:
|
|
169
|
+
return _sr.compose_prepare(body)
|
|
170
|
+
except Exception as exc: # noqa: BLE001
|
|
171
|
+
logger.warning("scenario-roleplay compose failed, using default: %s", exc)
|
|
172
|
+
opening = (body.get("OpeningQuestion") or "").strip() or DEFAULT_WELCOME
|
|
173
|
+
return {
|
|
174
|
+
"system_prompt": "You are a friendly AI English speaking partner. "
|
|
175
|
+
"Chat naturally in English and keep the conversation going.",
|
|
176
|
+
"welcome": opening, "speed": 1.0, "voice_gender": "female",
|
|
177
|
+
"welcome_source": "core-default",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# stop / farewell / invoke
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
def stop(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
184
|
+
return self._trtc.stop(body["TaskId"])
|
|
185
|
+
|
|
186
|
+
def farewell(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
187
|
+
lang = body.get("Lang", "en")
|
|
188
|
+
text = body.get("FarewellText") or DEFAULT_FAREWELL.get(lang, DEFAULT_FAREWELL["en"])
|
|
189
|
+
result = self._trtc.control(body["TaskId"], "ServerPushText", text,
|
|
190
|
+
interrupt=True, stop_after_play=True)
|
|
191
|
+
result["FarewellText"] = text
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
def invoke(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
195
|
+
"""push-to-talk:把缓存用户文本发给 AI 触发回复(空文本亦可,基于已有上下文)。"""
|
|
196
|
+
text = (body.get("Text") or "").strip()
|
|
197
|
+
return self._trtc.control(body["TaskId"], "InvokeLLM", text, interrupt=True)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""conversation-core 配置加载(从 .env 读三把钥匙 + Region + 两组 LLM)。
|
|
3
|
+
|
|
4
|
+
对齐 AI 客服 Skill:三把钥匙走扁平 .env(配合 verify-credentials.py 复用)。
|
|
5
|
+
陪练特有的场景/风格/难度默认值内置在 scenario-roleplay 能力里,core 不管。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
|
|
15
|
+
_BASE_DIR = Path(__file__).resolve().parent.parent # capabilities/conversation-core/
|
|
16
|
+
load_dotenv(_BASE_DIR / ".env.local")
|
|
17
|
+
load_dotenv(_BASE_DIR / ".env")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Region → TRTC OpenAPI Endpoint
|
|
21
|
+
REGION_PROFILES = {
|
|
22
|
+
"cn": {"endpoint": "trtc.tencentcloudapi.com", "region": "ap-guangzhou"},
|
|
23
|
+
"intl": {"endpoint": "trtc.intl.tencentcloudapi.com", "region": "ap-singapore"},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env(key: str, default: str = "") -> str:
|
|
28
|
+
return (os.getenv(key) or default).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TRTCCred:
|
|
33
|
+
sdk_app_id: int
|
|
34
|
+
sdk_secret_key: str
|
|
35
|
+
region: str
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def configured(self) -> bool:
|
|
39
|
+
return bool(self.sdk_app_id) and bool(self.sdk_secret_key)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def endpoint(self) -> str:
|
|
43
|
+
return REGION_PROFILES.get(self.region, REGION_PROFILES["intl"])["endpoint"]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def api_region(self) -> str:
|
|
47
|
+
return REGION_PROFILES.get(self.region, REGION_PROFILES["intl"])["region"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class TencentCloudCred:
|
|
52
|
+
secret_id: str
|
|
53
|
+
secret_key: str
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def configured(self) -> bool:
|
|
57
|
+
return bool(self.secret_id) and bool(self.secret_key)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class LLMCred:
|
|
62
|
+
api_key: str
|
|
63
|
+
api_url: str
|
|
64
|
+
model: str
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def configured(self) -> bool:
|
|
68
|
+
return bool(self.api_key)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CoreConfig:
|
|
72
|
+
"""core 运行期配置:三把钥匙 + 实时对话 LLM + 报告 LLM(共享评估基座用)。"""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
region = _env("TRTC_REGION", "intl")
|
|
76
|
+
if region not in REGION_PROFILES:
|
|
77
|
+
region = "intl"
|
|
78
|
+
self.trtc = TRTCCred(
|
|
79
|
+
sdk_app_id=int(_env("TRTC_SDK_APP_ID", "0") or 0),
|
|
80
|
+
sdk_secret_key=_env("TRTC_SDK_SECRET_KEY"),
|
|
81
|
+
region=region,
|
|
82
|
+
)
|
|
83
|
+
self.tencent = TencentCloudCred(
|
|
84
|
+
secret_id=_env("TENCENT_CLOUD_SECRET_ID"),
|
|
85
|
+
secret_key=_env("TENCENT_CLOUD_SECRET_KEY"),
|
|
86
|
+
)
|
|
87
|
+
# 实时对话 LLM(TRTC 云端 AI Bot 调用)
|
|
88
|
+
self.llm = LLMCred(
|
|
89
|
+
api_key=_env("LLM_API_KEY"),
|
|
90
|
+
api_url=_env("LLM_API_URL", "https://api.openai.com/v1/chat/completions"),
|
|
91
|
+
model=_env("LLM_MODEL", "gpt-4o-mini"),
|
|
92
|
+
)
|
|
93
|
+
# 报告/纠正/建议 LLM(后端直连;空则回退到实时对话 LLM 配置)
|
|
94
|
+
self.report_llm = LLMCred(
|
|
95
|
+
api_key=_env("REPORT_LLM_API_KEY") or self.llm.api_key,
|
|
96
|
+
api_url=_env("REPORT_LLM_API_URL") or self.llm.api_url,
|
|
97
|
+
model=_env("REPORT_LLM_MODEL") or self.llm.model,
|
|
98
|
+
)
|
|
99
|
+
self.report_llm_temperature = float(_env("REPORT_LLM_TEMPERATURE", "0.2") or 0.2)
|
|
100
|
+
self.report_llm_timeout = float(_env("REPORT_LLM_TIMEOUT", "120") or 120)
|
|
101
|
+
|
|
102
|
+
# ---- 实时对话 LLMConfig(注入动态 SystemPrompt 后传给 TRTC)----
|
|
103
|
+
def llm_config_with_prompt(self, system_prompt: str) -> dict:
|
|
104
|
+
# History: TRTC 服务端自动维护的上下文轮数,官方上限 50,做个保护
|
|
105
|
+
history = max(0, min(50, int(_env("LLM_HISTORY", "20") or 20)))
|
|
106
|
+
return {
|
|
107
|
+
"LLMType": "openai",
|
|
108
|
+
"Model": self.llm.model,
|
|
109
|
+
"APIKey": self.llm.api_key,
|
|
110
|
+
"APIUrl": self.llm.api_url,
|
|
111
|
+
"SystemPrompt": system_prompt,
|
|
112
|
+
"Streaming": True,
|
|
113
|
+
"Temperature": float(_env("LLM_TEMPERATURE", "0.4") or 0.4),
|
|
114
|
+
"History": history,
|
|
115
|
+
# 上下文与音频播放进度同步:未播完的音频对应文本不计入下一轮上下文,
|
|
116
|
+
# 避免"AI 自相矛盾/重复问候/像是在跟自己说话"这类问题。默认开启。
|
|
117
|
+
"HistoryMode": int(_env("LLM_HISTORY_MODE", "1") or 1),
|
|
118
|
+
# 原来没有 Timeout:实时对话模型响应稍慢时,TRTC 云端 AI Bot 可能拿不到
|
|
119
|
+
# LLM 回复而静默无输出。给个较宽松的默认值。
|
|
120
|
+
"Timeout": float(_env("LLM_TIMEOUT", "20") or 20),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ---- 数字人(三项全填启用)----
|
|
124
|
+
def avatar_enabled(self) -> bool:
|
|
125
|
+
return bool(_env("AVATAR_APPKEY")) and bool(_env("AVATAR_ACCESS_TOKEN")) \
|
|
126
|
+
and bool(_env("AVATAR_PROJECT_ID"))
|
|
127
|
+
|
|
128
|
+
def avatar_config(self) -> dict:
|
|
129
|
+
return {
|
|
130
|
+
"AvatarType": _env("AVATAR_TYPE", "tencent"),
|
|
131
|
+
"Appkey": _env("AVATAR_APPKEY"),
|
|
132
|
+
"AccessToken": _env("AVATAR_ACCESS_TOKEN"),
|
|
133
|
+
"VirtualmanProjectId": _env("AVATAR_PROJECT_ID"),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def fully_configured(self) -> bool:
|
|
138
|
+
return self.trtc.configured and self.tencent.configured and self.llm.configured
|
|
139
|
+
|
|
140
|
+
def missing(self) -> list:
|
|
141
|
+
m = []
|
|
142
|
+
if not self.trtc.configured: m.append("TRTC")
|
|
143
|
+
if not self.tencent.configured: m.append("TENCENT_CLOUD")
|
|
144
|
+
if not self.llm.configured: m.append("LLM")
|
|
145
|
+
return m
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_config() -> CoreConfig:
|
|
149
|
+
return CoreConfig()
|