@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,126 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Dify / Coze / user_custom KB adapters + 工厂。
3
+
4
+ - dify : POST {KB_DIFY_API_URL}/datasets/{id}/retrieve (Bearer KB_DIFY_API_KEY)
5
+ - coze : POST {KB_COZE_API_URL}/open_api/knowledge/recall (Bearer KB_COZE_API_KEY)
6
+ - user_custom : POST {KB_REST_BASE_URL} (Bearer KB_REST_TOKEN) —— 用户自研 REST
7
+
8
+ 所有 adapter 走 assert_safe_url 做 SSRF 防护。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from typing import Dict, List
15
+
16
+ import requests
17
+
18
+ from ..ports import KBClient, assert_safe_url
19
+
20
+ logger = logging.getLogger("custom-learning-kb.adapter")
21
+
22
+
23
+ def _env(k: str, d: str = "") -> str:
24
+ return (os.getenv(k) or d).strip()
25
+
26
+
27
+ class DifyKBClient(KBClient):
28
+ def __init__(self) -> None:
29
+ self.base = _env("KB_DIFY_API_URL")
30
+ self.key = _env("KB_DIFY_API_KEY")
31
+ self.dataset = _env("KB_DIFY_DATASET_ID")
32
+
33
+ def retrieve(self, query: str, top_k: int = 3) -> List[Dict]:
34
+ if not (self.base and self.key):
35
+ raise RuntimeError("KB_DIFY_API_URL / KB_DIFY_API_KEY not configured")
36
+ url = f"{self.base.rstrip('/')}/datasets/{self.dataset}/retrieve"
37
+ assert_safe_url(url)
38
+ headers = {"Authorization": f"Bearer {self.key}"}
39
+
40
+ def _payload(search_method: str) -> dict:
41
+ # Dify HitTestingPayload 的 retrieval_model 是强校验的 pydantic 模型,
42
+ # reranking_enable / score_threshold_enabled 是必填字段,缺了会直接 400。
43
+ return {
44
+ "query": query,
45
+ "retrieval_model": {
46
+ "search_method": search_method,
47
+ "reranking_enable": False,
48
+ "reranking_mode": None,
49
+ "reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
50
+ "weights": None,
51
+ "top_k": top_k,
52
+ "score_threshold_enabled": False,
53
+ "score_threshold": None,
54
+ },
55
+ }
56
+
57
+ # 优先 hybrid_search(语义检索,能理解查询词和素材内容之间没有字面重合但语义
58
+ # 相关的情况——这是场景检索的真实需求)。但 Dify 数据集若以「经济」模式建索引
59
+ # (免费/默认档常见),没有配置 Embedding 模型、也没有向量 Collection,请求
60
+ # hybrid_search 会报 400 "Collection not found"。这种情况下自动降级成
61
+ # keyword_search(关键词倒排索引,不依赖 Embedding)重试一次,保证「经济」
62
+ # 模式数据集也能用,只是检索质量会退化为关键词匹配。
63
+ resp = requests.post(url, headers=headers, json=_payload("hybrid_search"), timeout=(5, 8))
64
+ if resp.status_code == 400 and "Collection not found" in resp.text:
65
+ logger.warning("Dify hybrid_search unavailable (dataset likely uses Economy "
66
+ "indexing without an embedding model) — retrying with keyword_search")
67
+ resp = requests.post(url, headers=headers, json=_payload("keyword_search"), timeout=(5, 8))
68
+ resp.raise_for_status()
69
+ records = resp.json().get("records", [])
70
+ out = []
71
+ for r in records[:top_k]:
72
+ seg = r.get("segment", {}) if isinstance(r, dict) else {}
73
+ out.append({"text": seg.get("content", ""),
74
+ "source": (seg.get("document") or {}).get("name", "dify"),
75
+ "score": r.get("score", 0)})
76
+ return out
77
+
78
+
79
+ class CozeKBClient(KBClient):
80
+ def __init__(self) -> None:
81
+ self.base = _env("KB_COZE_API_URL", "https://api.coze.cn")
82
+ self.key = _env("KB_COZE_API_KEY")
83
+ self.dataset = _env("KB_COZE_DATASET_ID")
84
+
85
+ def retrieve(self, query: str, top_k: int = 3) -> List[Dict]:
86
+ if not self.key:
87
+ raise RuntimeError("KB_COZE_API_KEY not configured")
88
+ url = f"{self.base.rstrip('/')}/open_api/knowledge/recall"
89
+ assert_safe_url(url)
90
+ resp = requests.post(url, headers={"Authorization": f"Bearer {self.key}"},
91
+ json={"dataset_ids": [self.dataset] if self.dataset else [],
92
+ "query": query, "top_k": top_k},
93
+ timeout=(5, 8))
94
+ resp.raise_for_status()
95
+ chunks = resp.json().get("chunks", []) or resp.json().get("data", [])
96
+ out = []
97
+ for c in (chunks or [])[:top_k]:
98
+ if isinstance(c, dict):
99
+ out.append({"text": c.get("content") or c.get("text", ""),
100
+ "source": c.get("doc_name", "coze"), "score": c.get("score", 0)})
101
+ return out
102
+
103
+
104
+ class UserCustomKBClient(KBClient):
105
+ def __init__(self) -> None:
106
+ self.base = _env("KB_REST_BASE_URL")
107
+ self.token = _env("KB_REST_TOKEN")
108
+
109
+ def retrieve(self, query: str, top_k: int = 3) -> List[Dict]:
110
+ if not self.base:
111
+ raise RuntimeError("KB_REST_BASE_URL not configured")
112
+ assert_safe_url(self.base)
113
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
114
+ resp = requests.post(self.base, headers=headers,
115
+ json={"query": query, "top_k": top_k}, timeout=(5, 8))
116
+ resp.raise_for_status()
117
+ data = resp.json()
118
+ records = data.get("records") or data.get("results") or []
119
+ return [{"text": r.get("text", ""), "source": r.get("source", "custom"),
120
+ "score": r.get("score", 0)} for r in records[:top_k] if isinstance(r, dict)]
121
+
122
+
123
+ def get_client() -> KBClient:
124
+ adapter = _env("KB_ADAPTER", "dify").lower()
125
+ return {"dify": DifyKBClient, "coze": CozeKBClient,
126
+ "user_custom": UserCustomKBClient}.get(adapter, DifyKBClient)()
@@ -0,0 +1,46 @@
1
+ # -*- coding: utf-8 -*-
2
+ """KBClient port —— 检索用户自有教研知识库,返回相关片段。
3
+
4
+ adapter:dify(默认)/ coze / user_custom,通过 .env KB_ADAPTER 切换。
5
+ 安全:outbound 对非 localhost 强制 HTTPS + 禁止内网地址(SSRF 防护)。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import socket
11
+ from abc import ABC, abstractmethod
12
+ from typing import Dict, List
13
+ from urllib.parse import urlparse
14
+
15
+
16
+ class KBClient(ABC):
17
+ @abstractmethod
18
+ def retrieve(self, query: str, top_k: int = 3) -> List[Dict]:
19
+ """返回 [{text, source, score}, ...]"""
20
+ ...
21
+
22
+
23
+ # ---- SSRF 防护:拒绝内网/环回(localhost 除外用于本地联调)----
24
+ _BLOCKED_PREFIXES = ("9.", "10.", "11.", "21.", "30.", "172.", "192.168.")
25
+
26
+
27
+ def assert_safe_url(url: str) -> None:
28
+ parsed = urlparse(url)
29
+ if parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1"):
30
+ raise ValueError(f"KB endpoint must be HTTPS (got {parsed.scheme!r})")
31
+ host = parsed.hostname or ""
32
+ if host in ("localhost", "127.0.0.1"):
33
+ return
34
+ try:
35
+ ip = socket.gethostbyname(host)
36
+ except Exception:
37
+ ip = host
38
+ try:
39
+ addr = ipaddress.ip_address(ip)
40
+ if addr.is_private or addr.is_loopback or addr.is_link_local:
41
+ raise ValueError(f"KB endpoint resolves to a private/internal address: {ip}")
42
+ except ValueError as e:
43
+ if "private/internal" in str(e):
44
+ raise
45
+ if any(ip.startswith(p) for p in _BLOCKED_PREFIXES):
46
+ raise ValueError(f"KB endpoint points to a blocked internal range: {ip}")
@@ -0,0 +1,27 @@
1
+ # -*- coding: utf-8 -*-
2
+ """router.py —— custom-learning-kb REST(Path B)。挂载于 /api/v1/kb。"""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+
9
+ from .adapters.clients import get_client
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/retrieve")
15
+ def kb_retrieve(payload: Dict[str, Any]):
16
+ """检索教研片段。body = {query, top_k?}"""
17
+ query = (payload.get("query") or "").strip()
18
+ if not query:
19
+ raise HTTPException(status_code=400, detail="query is required")
20
+ top_k = int(payload.get("top_k", 3) or 3)
21
+ try:
22
+ records = get_client().retrieve(query, top_k)
23
+ return {"records": records}
24
+ except ValueError as e: # SSRF / 配置错误
25
+ raise HTTPException(status_code=400, detail=str(e))
26
+ except Exception as e: # noqa: BLE001
27
+ raise HTTPException(status_code=502, detail=f"KB upstream error: {e}")
@@ -0,0 +1,48 @@
1
+ # quick-correct —— 单句 Speak 风格纠正
2
+ name: quick-correct
3
+ version: "1.0.0"
4
+ type: capability
5
+ description: "用户每轮说完后,异步给一句话的纠正(correction)+ 更地道说法(better),双语并行。"
6
+
7
+ dependencies:
8
+ - { name: conversation-core, version: ">=1.0.0,<2.0.0" }
9
+
10
+ extensions:
11
+ - inject_at: server.router_extension
12
+ inline_code: |
13
+ from ._capability_loader import try_load_capability as _try
14
+ _m = _try("quick-correct", "src/router.py")
15
+ if _m is not None and hasattr(_m, "router"):
16
+ app.include_router(_m.router, prefix="/api/v1/correct", tags=["quick-correct"])
17
+
18
+ config:
19
+ max_sentence_length: { default: 800 } # 单句上限,防滥用
20
+ adapter: { default: "default", env: "QC_ADAPTER" }
21
+
22
+ endpoints:
23
+ - method: POST
24
+ path: /api/v1/correct
25
+ description: "提交一句话 → 返回纠正 + 更地道说法(无可纠正项返回 null)"
26
+
27
+ # 评估类能力 —— 共用 Evaluator Port(见 references/evaluator-port.md)
28
+ business_contract:
29
+ port_class: "src.ports.evaluator.Evaluator"
30
+ default_adapter: "src.adapters.default.LLMEvaluator"
31
+ customization_sop: "references/evaluator-port.md#1-evaluator-port"
32
+ external_apis:
33
+ - name: correct.submit
34
+ direction: inbound
35
+ method: POST
36
+ path: /api/v1/correct
37
+ request_schema: { sentence: string, scenario: string, level: string, language: string }
38
+ response_schema: { correction: "string|null", better: "string|null", explanation: string }
39
+
40
+ integration:
41
+ auto_adapters:
42
+ - { tech_stack: ["react","vue","angular"], adapter: frontend-spa }
43
+ - { tech_stack: ["express","next"], adapter: node-backend }
44
+ - { tech_stack: ["flask","fastapi"], adapter: python-backend }
45
+
46
+ acceptance:
47
+ - "提交一句话能返回 correction / better 字段"
48
+ - "判定无错时返回 null,前端不弹卡片"
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """quick-correct 能力包。"""
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """adapters 子包。"""
@@ -0,0 +1,106 @@
1
+ # -*- coding: utf-8 -*-
2
+ """quick-correct default adapter —— Speak 风格单句纠正(移植定稿 Demo evaluator)。
3
+
4
+ 多语言:总生成 en 版(fix/native 恒为英文,why 用英文),并按 ui_lang 生成对应版本
5
+ (why 用 ui_lang;支持 en/zh/ja/ko,其它降级 en)。并行调用共享评估基座。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from typing import Any, Dict, Optional
13
+
14
+ logger = logging.getLogger("quick-correct.adapter")
15
+
16
+
17
+ def _shared():
18
+ import coach_report_llm
19
+ return coach_report_llm.get_shared()
20
+
21
+
22
+ _TONE = {
23
+ "zh": ("## 输出语言与语气\n* `correction.fix` 与 `better.native` 是英文(不翻译)。\n"
24
+ "* `correction.why` 与 `better.why` **必须用简体中文**,教学+鼓励语气,点出为什么更像 native。\n"
25
+ "* 避免「错了/不对」,用「这里建议/更自然的说法是/听起来更像 native」。`why` 30~80 字。\n"),
26
+ "ja": ("## 出力言語とトーン\n* `correction.fix`/`better.native` は英語(翻訳しない)。\n"
27
+ "* `correction.why`/`better.why` は**日本語で**、教育的で励ましのトーン、なぜ自然かを説明。\n"
28
+ "* 「間違い/ダメ」を避け「こう言うと自然」。`why` は40〜100字。\n"),
29
+ "ko": ("## 출력 언어 및 톤\n* `correction.fix`/`better.native` 는 영어(번역 금지).\n"
30
+ "* `correction.why`/`better.why` 는 **한국어로**, 교육적·격려 톤, 왜 자연스러운지 설명.\n"
31
+ "* '틀림/잘못' 대신 '이렇게 말하면 자연스럽습니다'. `why` 40~100자.\n"),
32
+ "en": ("## Output language & tone\n* `correction.fix`/`better.native` are English (learning content).\n"
33
+ "* `correction.why`/`better.why` in English, teaching+encouraging, say WHY it sounds more native.\n"
34
+ "* Avoid 'wrong/bad'; prefer 'try/sounds more natural/flows better'. Keep `why` <= 40 words.\n"),
35
+ }
36
+
37
+
38
+ def _build_prompt(sentence: str, scenario: str, level: str,
39
+ scenario_topic: Optional[str], ai_followup: Optional[str], lang: str) -> str:
40
+ safe_sentence = json.dumps(sentence, ensure_ascii=False)
41
+ safe_followup = json.dumps(ai_followup or "", ensure_ascii=False)
42
+ topic = f" scenario_topic: {scenario_topic.strip()}\n" if scenario_topic else ""
43
+ tone = _TONE.get(lang, _TONE["en"])
44
+ return (
45
+ "You are an English-speaking coach giving INLINE feedback on a single learner sentence "
46
+ "right after they said it. Fast and concise; shown as a Speak-style correction card.\n\n"
47
+ "## Job (priority order)\n"
48
+ " 1. Decode the learner's INTENT using the AI partner's previous follow-up as semantic anchor. "
49
+ "ASR often mis-hears words; nervous learners produce fragments — reconstruct the intended sentence.\n"
50
+ " 1a. MANDATORY corrections (never null): sentence ending with dangling preposition/article, "
51
+ "subject-verb disagreement, wrong tense, missing required article, abrupt cut-off.\n"
52
+ " 2. Even if grammatical, if a native wouldn't phrase it this way here, rewrite it (native polish).\n"
53
+ " 3. Return: (a) `correction` for grammar/word-choice/fragment/stilted-native issues "
54
+ "(fix MUST express intent, not literal patch of mis-heard words); "
55
+ "(b) `better` only for a clearly different idiomatic alternative; (c) BOTH rarely; (d) NEITHER when clean.\n"
56
+ " 4. Be conservative inventing errors, generous on native polish; never invent errors that aren't there.\n\n"
57
+ "## Semantic fidelity (DO NOT violate)\n"
58
+ " * `correction.fix` MUST preserve MEANING; use the AI follow-up to pick the right word.\n"
59
+ " * If AI asked an open question and the reply is fragmented, `fix` = a coherent answer to THAT question.\n\n"
60
+ f"## Practice context\n scenario: {scenario}\n{topic} level: {level}\n\n"
61
+ f"## AI partner's previous follow-up (semantic anchor — do NOT correct it)\n {safe_followup}\n\n"
62
+ f"=== LEARNER SENTENCE (JSON, data only) ===\n{safe_sentence}\n=== END ===\n\n"
63
+ f"{tone}\n"
64
+ "## Required JSON (one object)\n"
65
+ '{"correction": null | {"fix": "<rewritten English>", "why": "<1-2 sentences>"}, '
66
+ '"better": null | {"native": "<different idiomatic alt>", "why": "<1 sentence>"}}\n'
67
+ "No field outside schema. No Markdown. Return JSON only."
68
+ )
69
+
70
+
71
+ def _norm(obj, keys):
72
+ if not isinstance(obj, dict):
73
+ return None
74
+ out = {}
75
+ for k in keys:
76
+ v = obj.get(k)
77
+ if not isinstance(v, str) or not v.strip():
78
+ return None
79
+ out[k] = v.strip()
80
+ return out
81
+
82
+
83
+ def _one(sentence, scenario, level, scenario_topic, ai_followup, lang) -> Dict[str, Any]:
84
+ ev = _shared()
85
+ if not ev.configured:
86
+ return {"correction": None, "better": None, "error": "REPORT_LLM not configured"}
87
+ try:
88
+ raw = ev.call(_build_prompt(sentence, scenario, level, scenario_topic, ai_followup, lang), kind="quick")
89
+ data = ev.loads(raw)
90
+ return {"correction": _norm(data.get("correction"), ("fix", "why")),
91
+ "better": _norm(data.get("better"), ("native", "why"))}
92
+ except Exception as e: # noqa: BLE001
93
+ logger.warning("quick(%s) failed: %s", lang, e)
94
+ return {"correction": None, "better": None, "error": str(e)}
95
+
96
+
97
+ def quick_correct_multilang(sentence: str, scenario: str, level: str,
98
+ scenario_topic: Optional[str], ai_followup: Optional[str],
99
+ ui_lang: str = "zh") -> Dict[str, Dict[str, Any]]:
100
+ langs = {"en"}
101
+ if ui_lang in ("zh", "ja", "ko"):
102
+ langs.add(ui_lang)
103
+ with ThreadPoolExecutor(max_workers=len(langs)) as pool:
104
+ futs = {lg: pool.submit(_one, sentence, scenario, level, scenario_topic, ai_followup, lg)
105
+ for lg in langs}
106
+ return {lg: f.result() for lg, f in futs.items()}
@@ -0,0 +1,33 @@
1
+ # -*- coding: utf-8 -*-
2
+ """handler.py —— /action facade 的 QuickCorrect(复刻定稿 Demo 响应形状)。"""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from .adapters.default import quick_correct_multilang
8
+
9
+ _LEVELS = {"beginner", "intermediate", "advanced"}
10
+ _SCENARIOS = {"travel", "work", "study", "free"}
11
+
12
+
13
+ def _ui_lang_key(raw: str) -> str:
14
+ r = (raw or "zh-CN").strip().lower()
15
+ if r.startswith("zh"): return "zh"
16
+ if r.startswith("ja"): return "ja"
17
+ if r.startswith("ko"): return "ko"
18
+ return "en"
19
+
20
+
21
+ def generate(body: Dict[str, Any]) -> Dict[str, Any]:
22
+ sentence = (body.get("UserSentence") or "").strip()
23
+ if not sentence:
24
+ raise ValueError("UserSentence is required")
25
+ sentence = sentence[:800]
26
+ scenario = body.get("Scenario") if body.get("Scenario") in _SCENARIOS else "free"
27
+ level = body.get("Level") if body.get("Level") in _LEVELS else "intermediate"
28
+ scenario_topic = (body.get("ScenarioTopic") or "").strip() or None
29
+ ai_followup = (body.get("AiFollowup") or "").strip() or None
30
+ turn_id = body.get("TurnId") or ""
31
+ ui_lang = _ui_lang_key(body.get("UILanguage") or "zh-CN")
32
+ corrections = quick_correct_multilang(sentence, scenario, level, scenario_topic, ai_followup, ui_lang)
33
+ return {"Corrections": corrections, "TurnId": turn_id}
@@ -0,0 +1,23 @@
1
+ # -*- coding: utf-8 -*-
2
+ """router.py —— quick-correct REST(Path B)。挂载于 /api/v1/correct。"""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+
9
+ from .handler import generate
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("")
15
+ @router.post("/")
16
+ def correct(payload: Dict[str, Any]):
17
+ """提交一句话 → 返回纠正 + 更地道说法(无可纠正项则字段为 null)。"""
18
+ try:
19
+ return generate(payload)
20
+ except ValueError as e:
21
+ raise HTTPException(status_code=400, detail=str(e))
22
+ except Exception as e: # noqa: BLE001
23
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,49 @@
1
+ # reply-suggestion —— Lightbulb 接话建议
2
+ name: reply-suggestion
3
+ version: "1.0.0"
4
+ type: capability
5
+ description: "AI 说完一句后,异步给用户 3 条不同方向的英文接话建议(卡住时的提示)。"
6
+
7
+ dependencies:
8
+ - { name: conversation-core, version: ">=1.0.0,<2.0.0" }
9
+
10
+ extensions:
11
+ - inject_at: server.router_extension
12
+ inline_code: |
13
+ from ._capability_loader import try_load_capability as _try
14
+ _m = _try("reply-suggestion", "src/router.py")
15
+ if _m is not None and hasattr(_m, "router"):
16
+ app.include_router(_m.router, prefix="/api/v1/suggest", tags=["reply-suggestion"])
17
+
18
+ config:
19
+ max_ai_message_length: { default: 1200 }
20
+ max_recent_turns: { default: 6 }
21
+ suggestions_count: { default: 3 }
22
+ adapter: { default: "default", env: "RS_ADAPTER" }
23
+
24
+ endpoints:
25
+ - method: POST
26
+ path: /api/v1/suggest
27
+ description: "提交 AI 最后一句 + 最近对话 → 返回 3 条接话建议"
28
+
29
+ business_contract:
30
+ port_class: "src.ports.evaluator.Evaluator"
31
+ default_adapter: "src.adapters.default.LLMEvaluator"
32
+ customization_sop: "references/evaluator-port.md#1-evaluator-port"
33
+ external_apis:
34
+ - name: suggest.replies
35
+ direction: inbound
36
+ method: POST
37
+ path: /api/v1/suggest
38
+ request_schema: { aiLastMessage: string, recentTranscript: "array", scenario: string, level: string, style: string }
39
+ response_schema: { suggestions: "string[3]" }
40
+
41
+ integration:
42
+ auto_adapters:
43
+ - { tech_stack: ["react","vue","angular"], adapter: frontend-spa }
44
+ - { tech_stack: ["express","next"], adapter: node-backend }
45
+ - { tech_stack: ["flask","fastapi"], adapter: python-backend }
46
+
47
+ acceptance:
48
+ - "返回 3 条方向不同的英文接话建议"
49
+ - "调用失败时前端静默降级,不阻塞主流程"
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """reply-suggestion 能力包。"""
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """adapters 子包。"""
@@ -0,0 +1,86 @@
1
+ # -*- coding: utf-8 -*-
2
+ """reply-suggestion default adapter —— Lightbulb 接话建议(移植定稿 Demo evaluator)。
3
+
4
+ 对 AI 最后一句生成 3 条方向不同的英文接话建议,全英文(用户要说出口的)。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ logger = logging.getLogger("reply-suggestion.adapter")
13
+
14
+
15
+ def _shared():
16
+ import coach_report_llm
17
+ return coach_report_llm.get_shared()
18
+
19
+
20
+ _LEVEL_HINT = {
21
+ "beginner": "Each hint is 5-10 words, A2-B1 vocabulary, simple structure.",
22
+ "intermediate": "Each hint is 8-15 words, B1-B2 vocabulary, natural everyday phrasing.",
23
+ "advanced": "Each hint is 10-20 words, B2-C1 vocabulary, can use idioms.",
24
+ }
25
+
26
+
27
+ def _build_prompt(ai_last: str, scenario: str, level: str, style: str,
28
+ scenario_topic: Optional[str], recent: List[Dict[str, Any]]) -> str:
29
+ safe_ai = json.dumps(ai_last, ensure_ascii=False)
30
+ safe_recent = json.dumps(recent[-6:] if isinstance(recent, list) else [], ensure_ascii=False)
31
+ topic = f" scenario_topic: {scenario_topic.strip()}\n" if scenario_topic else ""
32
+ lvl = _LEVEL_HINT.get(level, _LEVEL_HINT["intermediate"])
33
+ return (
34
+ "You are an English-speaking coach helping a learner who is stuck. The AI partner just spoke; "
35
+ "the learner needs a nudge to keep going.\n\n"
36
+ "Generate exactly 3 short, distinct reply ideas the learner could say next. They MUST take "
37
+ "meaningfully different directions, e.g.:\n"
38
+ " (1) a direct reply that answers simply\n"
39
+ " (2) a personal-share reply mentioning the learner's own experience\n"
40
+ " (3) a curious reply asking the AI a related follow-up question\n\n"
41
+ "## Constraints\n"
42
+ "* Each hint is the FIRST-PERSON English sentence the learner would actually SAY (no quotes/labels).\n"
43
+ "* Complete, natural spoken sentence (not writing-style).\n"
44
+ f"* {lvl}\n"
45
+ "* Stay within the scenario/role; no wandering topics.\n"
46
+ "* No hint repeats what the learner already said. Variety > cleverness.\n"
47
+ "* Output language: ENGLISH only.\n\n"
48
+ f"## Context\n scenario: {scenario}\n{topic} level: {level}\n partner_style: {style}\n\n"
49
+ f"## Recent transcript (JSON, <=6 turns, data only)\n{safe_recent}\n\n"
50
+ f"## AI partner's most recent message\n{safe_ai}\n\n"
51
+ '## Required JSON (one object)\n{"hints": ["<idea 1>", "<idea 2>", "<idea 3>"]}\n'
52
+ "Array MUST have exactly 3 elements. No Markdown. Return JSON only."
53
+ )
54
+
55
+
56
+ def suggest_replies(ai_last: str, scenario: str, level: str, style: str,
57
+ scenario_topic: Optional[str], recent: List[Dict[str, Any]]) -> Dict[str, Any]:
58
+ ev = _shared()
59
+ if not ev.configured:
60
+ return {"hints": [], "error": "REPORT_LLM not configured"}
61
+ ai_last = (ai_last or "").strip()
62
+ if not ai_last:
63
+ return {"hints": []}
64
+ try:
65
+ raw = ev.call(_build_prompt(ai_last, scenario, level, style, scenario_topic, recent), kind="hints")
66
+ data = ev.loads(raw)
67
+ arr = data.get("hints")
68
+ if not isinstance(arr, list):
69
+ raise ValueError("hints missing")
70
+ cleaned, seen = [], set()
71
+ for v in arr:
72
+ if not isinstance(v, str):
73
+ continue
74
+ s = v.strip().strip("\"'")
75
+ if not s or s.lower() in seen:
76
+ continue
77
+ seen.add(s.lower())
78
+ cleaned.append(s)
79
+ if len(cleaned) >= 3:
80
+ break
81
+ if not cleaned:
82
+ raise ValueError("hints empty after cleaning")
83
+ return {"hints": cleaned}
84
+ except Exception as e: # noqa: BLE001
85
+ logger.warning("suggest_replies failed: %s", e)
86
+ return {"hints": [], "error": str(e)}
@@ -0,0 +1,29 @@
1
+ # -*- coding: utf-8 -*-
2
+ """handler.py —— /action facade 的 SuggestReplies(复刻定稿 Demo 响应形状)。"""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from .adapters.default import suggest_replies
8
+
9
+ _LEVELS = {"beginner", "intermediate", "advanced"}
10
+ _SCENARIOS = {"travel", "work", "study", "free"}
11
+ _STYLES = {"friend", "listener", "local"}
12
+
13
+
14
+ def generate(body: Dict[str, Any]) -> Dict[str, Any]:
15
+ ai_last = (body.get("AiLastMessage") or "").strip()
16
+ if not ai_last:
17
+ raise ValueError("AiLastMessage is required")
18
+ ai_last = ai_last[:1200]
19
+ scenario = body.get("Scenario") if body.get("Scenario") in _SCENARIOS else "free"
20
+ level = body.get("Level") if body.get("Level") in _LEVELS else "intermediate"
21
+ style = body.get("Style") if body.get("Style") in _STYLES else "friend"
22
+ scenario_topic = (body.get("ScenarioTopic") or "").strip() or None
23
+ recent: List[Dict[str, Any]] = []
24
+ for item in (body.get("RecentTranscript") or [])[-6:]:
25
+ if isinstance(item, dict) and item.get("role") in ("user", "coach") \
26
+ and isinstance(item.get("text"), str) and item["text"].strip():
27
+ recent.append({"role": item["role"], "text": item["text"][:400]})
28
+ result = suggest_replies(ai_last, scenario, level, style, scenario_topic, recent)
29
+ return {"Hints": result.get("hints") or [], "HintId": body.get("HintId") or ""}
@@ -0,0 +1,23 @@
1
+ # -*- coding: utf-8 -*-
2
+ """router.py —— reply-suggestion REST(Path B)。挂载于 /api/v1/suggest。"""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+
9
+ from .handler import generate
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("")
15
+ @router.post("/")
16
+ def suggest(payload: Dict[str, Any]):
17
+ """AI 最后一句 + 最近对话 → 3 条接话建议。"""
18
+ try:
19
+ return generate(payload)
20
+ except ValueError as e:
21
+ raise HTTPException(status_code=400, detail=str(e))
22
+ except Exception as e: # noqa: BLE001
23
+ raise HTTPException(status_code=500, detail=str(e))