@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.
Files changed (85) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/CODEBUDDY.md +1 -1
  4. package/README.md +6 -73
  5. package/README.zh.md +6 -69
  6. package/bin/cli.js +9 -7
  7. package/package.json +1 -1
  8. package/skills/trtc/SKILL.md +55 -4
  9. package/skills/trtc-ai-oral-coach/README.ja.md +131 -0
  10. package/skills/trtc-ai-oral-coach/README.md +132 -0
  11. package/skills/trtc-ai-oral-coach/README.zh-CN.md +131 -0
  12. package/skills/trtc-ai-oral-coach/SKILL.md +395 -0
  13. package/skills/trtc-ai-oral-coach/auto_adapters/integration_templates/custom-learning-kb-sample.md +88 -0
  14. package/skills/trtc-ai-oral-coach/auto_adapters/integration_templates/generic-integration.md +104 -0
  15. package/skills/trtc-ai-oral-coach/auto_adapters/manifest.yaml +27 -0
  16. package/skills/trtc-ai-oral-coach/auto_adapters/python/README.md +49 -0
  17. package/skills/trtc-ai-oral-coach/auto_adapters/python/coach_client.py.tpl +185 -0
  18. package/skills/trtc-ai-oral-coach/auto_adapters/web/README.md +46 -0
  19. package/skills/trtc-ai-oral-coach/auto_adapters/web/oral-coach-client.js.tpl +210 -0
  20. package/skills/trtc-ai-oral-coach/capabilities/ability-report/manifest.yaml +52 -0
  21. package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/__init__.py +2 -0
  22. package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/adapters/__init__.py +2 -0
  23. package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/adapters/default.py +177 -0
  24. package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/handler.py +29 -0
  25. package/skills/trtc-ai-oral-coach/capabilities/ability-report/src/router.py +23 -0
  26. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/.env.example +59 -0
  27. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/manifest.yaml +46 -0
  28. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/requirements.txt +6 -0
  29. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/TLSSigAPIv2.py +275 -0
  30. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/__init__.py +2 -0
  31. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/_capability_loader.py +227 -0
  32. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/agent.py +197 -0
  33. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/config.py +149 -0
  34. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/core/__init__.py +2 -0
  35. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/core/report_llm.py +129 -0
  36. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/health.py +36 -0
  37. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/server.py +205 -0
  38. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/trtc_client.py +86 -0
  39. package/skills/trtc-ai-oral-coach/capabilities/conversation-core/src/usersig.py +33 -0
  40. package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/manifest.yaml +65 -0
  41. package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/__init__.py +2 -0
  42. package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/adapters/__init__.py +2 -0
  43. package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/adapters/clients.py +126 -0
  44. package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/ports.py +46 -0
  45. package/skills/trtc-ai-oral-coach/capabilities/custom-learning-kb/src/router.py +27 -0
  46. package/skills/trtc-ai-oral-coach/capabilities/quick-correct/manifest.yaml +48 -0
  47. package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/__init__.py +2 -0
  48. package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/adapters/__init__.py +2 -0
  49. package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/adapters/default.py +106 -0
  50. package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/handler.py +33 -0
  51. package/skills/trtc-ai-oral-coach/capabilities/quick-correct/src/router.py +23 -0
  52. package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/manifest.yaml +49 -0
  53. package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/__init__.py +2 -0
  54. package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/adapters/__init__.py +2 -0
  55. package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/adapters/default.py +86 -0
  56. package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/handler.py +29 -0
  57. package/skills/trtc-ai-oral-coach/capabilities/reply-suggestion/src/router.py +23 -0
  58. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/data/practice-scenarios.json +500 -0
  59. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/manifest.yaml +54 -0
  60. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/__init__.py +2 -0
  61. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/adapters/__init__.py +2 -0
  62. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/adapters/default.py +113 -0
  63. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/compose.py +143 -0
  64. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/defaults.py +140 -0
  65. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/handler.py +29 -0
  66. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/router.py +22 -0
  67. package/skills/trtc-ai-oral-coach/capabilities/scenario-roleplay/src/scenario_source.py +179 -0
  68. package/skills/trtc-ai-oral-coach/references/design-specs.md +36 -0
  69. package/skills/trtc-ai-oral-coach/references/evaluator-port.md +75 -0
  70. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/recipe.yaml +86 -0
  71. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/avatars/friend.png +0 -0
  72. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/avatars/listener.png +0 -0
  73. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/avatars/local.png +0 -0
  74. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/coach.html +1986 -0
  75. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/i18n.js +323 -0
  76. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/practice-scenarios.json +500 -0
  77. package/skills/trtc-ai-oral-coach/scenarios/speaking-coach/ui/tokens.css +100 -0
  78. package/skills/trtc-ai-oral-coach/scripts/add-capability.py +256 -0
  79. package/skills/trtc-ai-oral-coach/scripts/verify-credentials.py +96 -0
  80. package/skills/trtc-ai-oral-coach/start.sh +59 -0
  81. package/skills/trtc-ai-oral-coach/triggers.yaml +32 -0
  82. package/skills/trtc-ai-service/README.ja.md +15 -32
  83. package/skills/trtc-ai-service/README.md +15 -32
  84. package/skills/trtc-ai-service/README.zh-CN.md +15 -32
  85. 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()
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """conversation-core/src/core 子包。"""