chore: create Multica handoff checkpoint

This commit is contained in:
leefer
2026-08-06 22:54:46 +08:00
parent bd97ba1829
commit 33f9db43b1
51 changed files with 3054 additions and 2883 deletions
+76 -1
View File
@@ -15,6 +15,11 @@ class MentorAgentError(RuntimeError):
pass
FOLLOW_UP_START = "<XIAOBAI_FOLLOW_UPS>"
FOLLOW_UP_END = "</XIAOBAI_FOLLOW_UPS>"
MAX_FOLLOW_UP_LENGTH = 80
@dataclass(frozen=True)
class MentorSkill:
skill_id: str
@@ -185,6 +190,8 @@ def stream_with_mentor(
base_url: str,
model: str,
timeout: int = 90,
*,
follow_ups: list[str] | None = None,
) -> Iterator[str]:
if not api_key or not model:
raise MentorAgentError("LLM API Key 或模型尚未配置。")
@@ -193,8 +200,10 @@ def stream_with_mentor(
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history[-10:])
messages.append({"role": "user", "content": question})
if follow_ups is not None:
follow_ups.clear()
try:
yield from llm_transport.stream_chat_completion(
upstream = llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
@@ -202,6 +211,7 @@ def stream_with_mentor(
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.6",
)
yield from _stream_answer_and_collect_follow_ups(upstream, follow_ups)
except llm_transport.OpenAIEmptyResponseError as exc:
raise MentorAgentError("问师模型未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
@@ -223,6 +233,10 @@ def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) ->
5. 优先回答用户真正的问题。市场分析通常按“判断、数据依据、思维模型下的应对、失效条件”组织;纯交易心理或方法问题可以自然回答,不强制套模板。
6. 保留该 Skill 的核心心智模型和表达节奏,但不要复述身份履历,不要宣称自己就是真人,不攻击或贬低用户。
7. 使用中文,信息密度高,避免空泛口号。引用数字时标明数据日期。
8. 正文结束后必须输出2至3条与本轮问题和正文直接相关的追问。追问用于帮助用户继续核实条件、风险或失效边界,不得引入正文没有依据的新事实,不得给出无条件买卖指令。严格使用以下机器结构,不要放进Markdown代码块,结束标签后不要再输出文字:
<XIAOBAI_FOLLOW_UPS>
["追问一?","追问二?","追问三?"]
</XIAOBAI_FOLLOW_UPS>
网页市场数据:
{context_json}
@@ -233,6 +247,67 @@ def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) ->
""".strip()
def _stream_answer_and_collect_follow_ups(
chunks: Iterator[str], follow_ups: list[str] | None
) -> Iterator[str]:
buffer = ""
collecting = False
for raw_chunk in chunks:
chunk = str(raw_chunk or "")
if not chunk:
continue
buffer += chunk
if collecting:
continue
marker_index = buffer.find(FOLLOW_UP_START)
if marker_index >= 0:
if marker_index:
yield buffer[:marker_index]
buffer = buffer[marker_index + len(FOLLOW_UP_START):]
collecting = True
continue
overlap = _marker_prefix_overlap(buffer, FOLLOW_UP_START)
emit_length = len(buffer) - overlap
if emit_length:
yield buffer[:emit_length]
buffer = buffer[emit_length:]
if not collecting:
if buffer:
yield buffer
return
raw_follow_ups = buffer.split(FOLLOW_UP_END, 1)[0].strip()
parsed = _parse_follow_ups(raw_follow_ups)
if follow_ups is not None and len(parsed) >= 2:
follow_ups.extend(parsed)
def _marker_prefix_overlap(value: str, marker: str) -> int:
max_length = min(len(value), len(marker) - 1)
for length in range(max_length, 0, -1):
if value.endswith(marker[:length]):
return length
return 0
def _parse_follow_ups(payload: str) -> list[str]:
try:
values = json.loads(payload)
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(values, list):
return []
result: list[str] = []
for value in values:
question = re.sub(r"\s+", " ", str(value or "")).strip()
if not question or len(question) > MAX_FOLLOW_UP_LENGTH or question in result:
continue
result.append(question)
if len(result) == 3:
break
return result
def _parse_frontmatter(content: str) -> dict[str, str]:
if not content.startswith("---"):
return {}
+25 -1
View File
@@ -129,9 +129,10 @@ class MentorServiceMixin:
def generate():
answer_parts: list[str] = []
follow_ups: list[str] = []
events = self.llm_gateway.stream(
"mentor",
f"mentor-skill-v1:{skill.skill_id}",
f"mentor-skill-v2:{skill.skill_id}",
lambda profile: stream_with_mentor(
skill,
context,
@@ -140,6 +141,7 @@ class MentorServiceMixin:
profile.api_key,
profile.base_url,
profile.model,
follow_ups=follow_ups,
),
(MentorAgentError,),
)
@@ -160,6 +162,7 @@ class MentorServiceMixin:
yield {
"type": "meta",
"data_trade_date": context["data_trade_date"],
"follow_ups": follow_ups or self._mentor_follow_up_fallback(question),
"notice": "智能解读已自动切换可用服务。"
if event.role == "fallback"
else "",
@@ -167,6 +170,27 @@ class MentorServiceMixin:
return generate()
@staticmethod
def _mentor_follow_up_fallback(question: str) -> list[str]:
normalized = question.strip()
if any(keyword in normalized for keyword in ("风险", "亏损", "回撤", "止损")):
return [
"这些风险最早会从哪些信号中暴露?",
"哪些变化会让当前风险判断失效?",
"如果风险继续扩大,仓位预案应如何调整?",
]
if any(keyword in normalized for keyword in ("股票", "个股", "代码", "怎么看")):
return [
"这个判断最关键的确认信号是什么?",
"哪些变化会让当前结论失效?",
"明日盘中应该优先观察哪些数据?",
]
return [
"这个判断最关键的确认依据是什么?",
"哪些变化会让当前结论失效?",
"下一步应该优先观察什么?",
]
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)