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 {}