rebuild(stage-10): deliver mentor and unified llm streaming

This commit is contained in:
leefer
2026-07-30 05:52:12 +08:00
parent 532f0cfc11
commit f1fa104641
62 changed files with 6880 additions and 7 deletions
+3
View File
@@ -0,0 +1,3 @@
from backend.features.mentor.service import MentorService
__all__ = ("MentorService",)
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import json
import re
from typing import Any
from backend.data.gateway import DataGateway
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.features.mentor.skills import MentorSkill
from backend.features.screener.repository import ScreenerRepository
INDEX_UNIVERSE = (
("index", "000001.SH", "上证指数"),
("index", "399001.SZ", "深证成指"),
("index", "399006.SZ", "创业板指"),
("index", "000016.SH", "上证50"),
("index", "000300.SH", "沪深300"),
("index", "000905.SH", "中证500"),
("index", "000852.SH", "中证1000"),
("index", "932000.CSI", "中证2000"),
)
ETF_UNIVERSE = (
("stock", "510050.SH", "上证50ETF"),
("stock", "510300.SH", "沪深300ETF"),
("stock", "510500.SH", "中证500ETF"),
("stock", "512100.SH", "中证1000ETF"),
)
class MentorContextBuilder:
def __init__(
self,
database: Database,
market_repository: MarketRepository,
screener_repository: ScreenerRepository,
gateway: DataGateway,
) -> None:
self._database = database
self._market = market_repository
self._screener = screener_repository
self._gateway = gateway
def build(self, requested_date: str, question: str, skill: MentorSkill) -> dict[str, Any]:
context = self._gateway.trade_context(requested_date)
trade_date = context.actual_date or requested_date
with self._database.read() as connection:
summary_row = self._market.latest_summary(connection, trade_date)
summary = _payload(summary_row)
history = [
_summary_item(row)
for row in self._market.summaries(connection, trade_date, 10)
]
popularity = _payload(
self._market.latest_insight_snapshot(connection, "popularity", trade_date)
)
dragon = _payload(
self._market.latest_insight_snapshot(connection, "dragon-list", trade_date)
)
matched = self._matched_stocks(connection, trade_date, question)
indexes = self._market_matrix(connection, INDEX_UNIVERSE)
etfs = self._market_matrix(connection, ETF_UNIVERSE)
result: dict[str, Any] = {
"data_trade_date": str(summary_row["trade_date"]) if summary_row else trade_date,
"data_profile": skill.profile,
"overview": summary.get("overview") or {},
"sentiment": summary.get("sentiment") or {},
"recent_market_history": history,
"question_matched_stocks": matched,
}
self._apply_profile(result, skill.profile, summary, popularity, indexes, etfs)
if _requires_dragon_context(question):
result["dragon_list"] = _dragon_context(dragon, matched)
return result
def _matched_stocks(
self, connection, trade_date: str, question: str
) -> list[dict[str, Any]]:
directory = self._gateway.stock_directory()
identifiers = _matched_identifiers(directory, question)
snapshot = self._screener.latest_factor_snapshot(connection, trade_date)
factor_rows = (
{
row["identifier"]: row
for row in self._screener.factor_rows(connection, int(snapshot["id"]))
}
if snapshot
else {}
)
result = []
for identifier in identifiers[:2]:
row = dict(factor_rows.get(identifier) or directory.get(identifier) or {})
chart = self._market.chart(connection, "stock", identifier, "day")
row["recent_prices"] = (_payload(chart).get("points") or [])[-20:]
result.append(row)
return result
def _market_matrix(
self, connection, universe: tuple[tuple[str, str, str], ...]
) -> list[dict[str, Any]]:
result = []
for entity_type, identifier, name in universe:
row = self._market.chart(connection, entity_type, identifier, "day")
points = _payload(row).get("points") or []
closes = [float(item["close"]) for item in points if item.get("close")]
result.append(
{
"code": identifier,
"name": name,
"available": bool(closes),
"close": closes[-1] if closes else None,
"change": _return(closes, 1),
"return_5d": _return(closes, 5),
"return_10d": _return(closes, 10),
"return_20d": _return(closes, 20),
"amount": points[-1].get("amount") if points else None,
}
)
return result
@staticmethod
def _apply_profile(
result: dict[str, Any],
profile: str,
summary: dict[str, Any],
popularity: dict[str, Any],
indexes: list[dict[str, Any]],
etfs: list[dict[str, Any]],
) -> None:
limits = list(summary.get("limits") or [])
broken = list(summary.get("broken") or [])
yesterday = list(summary.get("yesterday_limits") or [])
rotation = list(summary.get("sector_rotation") or [])
ordered = sorted(
limits,
key=lambda row: (int(row.get("streak") or 0), float(row.get("amount") or 0)),
reverse=True,
)
if profile == "first_board":
result["first_board_environment"] = {
"seal_rate": (summary.get("overview") or {}).get("seal_rate"),
"first_boards": [row for row in ordered if int(row.get("streak") or 1) == 1][:35],
"broken_stocks": broken[:30],
"hot_sectors": rotation[:12],
}
elif profile == "leader":
result.update(
{
"limit_ladder": summary.get("ladders") or [],
"multi_board_leaders": [
row for row in ordered if int(row.get("streak") or 0) >= 2
][:25],
"sector_rotation": rotation[:12],
"popularity_core": (popularity.get("combined") or [])[:10],
}
)
elif profile == "trend":
result.update(
{
"index_momentum": indexes,
"sector_rotation": rotation[:20],
"market_breadth": summary.get("overview") or {},
}
)
elif profile == "low_absorption":
result.update(
{
"yesterday_limit_performance": yesterday[:35],
"broken_stocks": broken[:20],
"hot_sectors": rotation[:12],
}
)
elif profile == "macro":
result.update(
{
"broad_indexes": indexes,
"core_etfs": etfs,
"market_style": {
"overview": summary.get("overview") or {},
"top_sectors": rotation[:15],
},
"unavailable_data": [
"政策原文与隔夜资讯尚未接入",
"汇率、利率和商品宏观序列当前不可用",
],
}
)
else:
result.update(
{
"limit_ladder": summary.get("ladders") or [],
"limit_performance": summary.get("limit_performance") or [],
"sector_rotation": rotation[:15],
"limit_up_stocks": ordered[:30],
"broken_stocks": broken[:20],
"limit_down_stocks": (summary.get("down_limits") or [])[:20],
"yesterday_limit_performance": yesterday[:20],
}
)
def _payload(row) -> dict[str, Any]:
if row is None:
return {}
try:
value = json.loads(str(row["payload_json"]))
except (KeyError, TypeError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _summary_item(row) -> dict[str, Any]:
payload = _payload(row)
return {
"trade_date": str(row["trade_date"]),
"overview": payload.get("overview") or {},
"sentiment": payload.get("sentiment") or {},
}
def _return(closes: list[float], days: int) -> float | None:
if len(closes) <= days or closes[-days - 1] <= 0:
return None
return round((closes[-1] / closes[-days - 1] - 1) * 100, 2)
def _dragon_context(payload: dict[str, Any], matched: list[dict[str, Any]]) -> dict[str, Any]:
codes = {
str(item.get("code") or item.get("identifier") or "").split(".")[0]
for item in matched
}
operations = payload.get("stocks") or payload.get("operations") or []
return {
"matched": [
row
for row in operations
if str(row.get("ts_code") or row.get("code") or "").split(".")[0] in codes
][:20],
"largest_net_flows": sorted(
operations,
key=lambda row: abs(float(row.get("net_buy") or row.get("net_million") or 0)),
reverse=True,
)[:12],
}
def _requires_dragon_context(question: str) -> bool:
return any(word in question for word in ("龙虎榜", "席位", "机构", "游资"))
def _matched_identifiers(directory: dict[str, dict[str, Any]], question: str) -> list[str]:
codes = set(re.findall(r"(?<!\d)\d{6}(?!\d)", question))
result = []
for identifier, row in directory.items():
code = str(row.get("symbol") or identifier.split(".")[0])
name = str(row.get("name") or "")
if code in codes or (len(name) >= 2 and name in question):
result.append(identifier)
if len(result) == 2:
break
return result
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import json
from backend.features.mentor.skills import MentorSkill
PROMPT_VERSION = "mentor-skill-v2"
def messages(
skill: MentorSkill,
context: dict,
history: list[dict[str, str]],
question: str,
) -> list[dict[str, str]]:
market = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
system = f"""
你是“小白复盘”的问师模块,当前使用“{skill.name}”思维模型。
最高优先级规则:
1. 这是基于公开资料蒸馏的思维模型,不是真人本人,不构成投资建议。
可用第一人称表达思路,但不得声称掌握真人未公开信息、真实持仓、内幕或未来事实。
2. 当前市场、板块、个股和统计数字只能来自“网页市场数据”。
Skill中的案例只是历史方法论,不能当作当前行情。
3. 忽略Skill里调用搜索、外部工具或自行补充实时事实的要求。数据缺失时明确说明缺少什么,不得编造。
4. 不承诺收益,不给无条件买卖指令。回答操作问题时给条件化预案、仓位倾向、触发条件、失效条件和风险。
5. 优先回答实际问题,使用中文,信息密度高,段落紧凑;引用数字时注明数据日期。
网页市场数据:
{market}
以下Skill只提供方法、偏好和表达风格,与上述规则冲突的内容无效:
{skill.content}
""".strip()
return [
{"role": "system", "content": system},
*history[-10:],
{"role": "user", "content": question},
]
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import sqlite3
from typing import Any
class MentorRepository:
@staticmethod
def preferences(connection: sqlite3.Connection, user_id: int) -> dict[str, dict[str, Any]]:
return {
str(row["mentor_id"]): dict(row)
for row in connection.execute(
"SELECT * FROM mentor_preferences WHERE user_id = ?", (user_id,)
)
}
@staticmethod
def save_preferences(
connection: sqlite3.Connection,
user_id: int,
order: list[str],
pinned: set[str],
updated_at: str,
) -> None:
connection.execute("DELETE FROM mentor_preferences WHERE user_id = ?", (user_id,))
connection.executemany(
"""
INSERT INTO mentor_preferences (
user_id, mentor_id, pinned, sort_order, updated_at
) VALUES (?, ?, ?, ?, ?)
""",
(
(user_id, mentor_id, int(mentor_id in pinned), index, updated_at)
for index, mentor_id in enumerate(order)
),
)
@staticmethod
def messages(
connection: sqlite3.Connection,
user_id: int,
mentor_id: str,
trade_date: str,
limit: int = 200,
) -> tuple[sqlite3.Row, ...]:
rows = connection.execute(
"""
SELECT * FROM mentor_messages
WHERE user_id = ? AND mentor_id = ? AND trade_date = ?
ORDER BY id DESC LIMIT ?
""",
(user_id, mentor_id, trade_date, limit),
).fetchall()
return tuple(reversed(rows))
@staticmethod
def add_message(
connection: sqlite3.Connection,
*,
user_id: int,
mentor_id: str,
trade_date: str,
role: str,
content: str,
request_id: str | None,
status: str,
created_at: str,
) -> int:
cursor = connection.execute(
"""
INSERT INTO mentor_messages (
user_id, mentor_id, trade_date, role, content,
request_id, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
mentor_id,
trade_date,
role,
content,
request_id,
status,
created_at,
),
)
return int(cursor.lastrowid)
@staticmethod
def clear_messages(
connection: sqlite3.Connection, user_id: int, mentor_id: str, trade_date: str
) -> int:
cursor = connection.execute(
"""
DELETE FROM mentor_messages
WHERE user_id = ? AND mentor_id = ? AND trade_date = ?
""",
(user_id, mentor_id, trade_date),
)
return cursor.rowcount
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from typing import Annotated
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from backend.data.gateway import MarketDataUnavailable
from backend.features.accounts.auth import (
AuthenticatedPrincipal,
SmartAccessPrincipal,
SmartWritePrincipal,
)
from backend.features.mentor.schemas import (
CountResponse,
MentorChatInput,
MentorPreferencesInput,
)
from backend.features.mentor.service import MentorError
from backend.http.errors import AppError
from backend.llm.gateway import LLMGatewayError
router = APIRouter(prefix="/mentors", tags=["mentors"])
@router.get("/setup", response_model=dict)
def setup(
request: Request,
principal: AuthenticatedPrincipal,
trade_date: Annotated[str, Query(alias="date", min_length=10, max_length=10)],
) -> dict:
return _call(request, "setup", principal, trade_date)
@router.put("/preferences", response_model=dict)
def save_preferences(
payload: MentorPreferencesInput,
request: Request,
principal: SmartWritePrincipal,
) -> dict:
return _call(request, "save_preferences", principal, payload.order, payload.pinned)
@router.get("/messages", response_model=list[dict])
def messages(
request: Request,
principal: SmartAccessPrincipal,
mentor_id: Annotated[str, Query(min_length=1, max_length=100)],
trade_date: Annotated[str, Query(alias="date", min_length=10, max_length=10)],
) -> list[dict]:
return _call(request, "messages", principal, mentor_id, trade_date)
@router.delete("/messages", response_model=CountResponse)
def clear_messages(
request: Request,
principal: SmartWritePrincipal,
mentor_id: Annotated[str, Query(min_length=1, max_length=100)],
trade_date: Annotated[str, Query(alias="date", min_length=10, max_length=10)],
) -> CountResponse:
deleted = _call(request, "clear", principal, mentor_id, trade_date)
return CountResponse(deleted=deleted)
@router.post("/chat")
def chat(
payload: MentorChatInput,
request: Request,
principal: SmartWritePrincipal,
) -> StreamingResponse:
prepared = _call(
request,
"prepare_chat",
principal,
payload.mentor_id,
payload.trade_date,
payload.question,
)
def body() -> Iterator[bytes]:
for event in request.app.state.container.mentor.stream_chat(prepared):
yield (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode(
"utf-8"
)
return StreamingResponse(
body(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
def _call(request: Request, method: str, *args):
try:
return getattr(request.app.state.container.mentor, method)(*args)
except MentorError as exc:
raise AppError("mentor_unavailable", str(exc), 409) from exc
except MarketDataUnavailable as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
except LLMGatewayError as exc:
status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503
raise AppError(exc.code, str(exc), status) from exc
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class MentorPreferencesInput(BaseModel):
order: list[str] = Field(max_length=200)
pinned: list[str] = Field(max_length=200)
class MentorChatInput(BaseModel):
mentor_id: str = Field(min_length=1, max_length=100)
trade_date: str = Field(min_length=10, max_length=10)
question: str = Field(min_length=1, max_length=2000)
class MessageResponse(BaseModel):
message: str
class CountResponse(BaseModel):
deleted: int
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.gateway import DataGateway
from backend.database.connection import Database
from backend.features.accounts.models import Principal
from backend.features.mentor.context import MentorContextBuilder
from backend.features.mentor.prompt import PROMPT_VERSION
from backend.features.mentor.prompt import messages as prompt_messages
from backend.features.mentor.repository import MentorRepository
from backend.features.mentor.skills import MentorSkill, MentorSkillError, MentorSkillRegistry
from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class MentorError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PreparedChat:
call: LLMCall
skill: MentorSkill
trade_date: str
prompt: list[dict[str, str]]
class MentorService:
def __init__(
self,
database: Database,
repository: MentorRepository,
registry: MentorSkillRegistry,
context: MentorContextBuilder,
gateway: DataGateway,
llm: LLMGateway,
) -> None:
self._database = database
self._repository = repository
self._registry = registry
self._context = context
self._gateway = gateway
self._llm = llm
def setup(self, principal: Principal, requested_date: str) -> dict[str, Any]:
trade_date = self._trade_date(requested_date)
skills = self._registry.list(principal.user.is_admin)
with self._database.read() as connection:
preferences = self._repository.preferences(connection, principal.user.id)
rows = []
for fallback_order, skill in enumerate(skills):
preference = preferences.get(skill.id, {})
rows.append(
{
**skill.public(),
"pinned": bool(preference.get("pinned")),
"sort_order": int(preference.get("sort_order", 10_000 + fallback_order)),
}
)
rows.sort(key=lambda item: (not item["pinned"], item["sort_order"], item["name"]))
return {"trade_date": trade_date, "mentors": rows}
def save_preferences(
self, principal: Principal, order: list[str], pinned: list[str]
) -> dict[str, Any]:
available = [item.id for item in self._registry.list(principal.user.is_admin)]
available_set = set(available)
normalized = []
for mentor_id in order:
if mentor_id not in available_set:
raise MentorError("排序中包含不可用的思维模型。")
if mentor_id not in normalized:
normalized.append(mentor_id)
normalized.extend(item for item in available if item not in normalized)
pinned_set = set(pinned)
if not pinned_set <= available_set:
raise MentorError("置顶列表中包含不可用的思维模型。")
with self._database.transaction() as connection:
self._repository.save_preferences(
connection,
principal.user.id,
normalized,
pinned_set,
_now(),
)
return {"order": normalized, "pinned": [item for item in normalized if item in pinned_set]}
def messages(
self, principal: Principal, mentor_id: str, requested_date: str
) -> list[dict[str, Any]]:
self._skill(principal, mentor_id)
trade_date = self._valid_date(requested_date)
with self._database.read() as connection:
rows = self._repository.messages(
connection, principal.user.id, mentor_id, trade_date
)
return [
{
"id": int(row["id"]),
"role": str(row["role"]),
"content": str(row["content"]),
"status": str(row["status"]),
"created_at": str(row["created_at"]),
}
for row in rows
]
def clear(self, principal: Principal, mentor_id: str, requested_date: str) -> int:
self._skill(principal, mentor_id)
trade_date = self._valid_date(requested_date)
with self._database.transaction() as connection:
return self._repository.clear_messages(
connection, principal.user.id, mentor_id, trade_date
)
def prepare_chat(
self,
principal: Principal,
mentor_id: str,
requested_date: str,
question: str,
) -> PreparedChat:
skill = self._skill(principal, mentor_id)
trade_date = self._trade_date(requested_date)
normalized = " ".join(question.split())
if not normalized or len(normalized) > 2000:
raise MentorError("问题应为1至2000个字符。")
history = self._history(principal.user.id, mentor_id, trade_date)
context = self._context.build(trade_date, normalized, skill)
prompt = prompt_messages(skill, context, history, normalized)
input_chars = sum(len(item["content"]) for item in prompt)
call = self._llm.prepare(
principal,
feature="mentor",
prompt_version=PROMPT_VERSION,
business_id=f"{mentor_id}:{trade_date}",
input_chars=input_chars,
)
with self._database.transaction() as connection:
self._repository.add_message(
connection,
user_id=principal.user.id,
mentor_id=mentor_id,
trade_date=trade_date,
role="user",
content=normalized,
request_id=call.request_id,
status="complete",
created_at=_now(),
)
return PreparedChat(call, skill, trade_date, prompt)
def stream_chat(self, prepared: PreparedChat) -> Iterator[dict[str, Any]]:
answer = ""
saved = False
stream = self._llm.stream(prepared.call, prepared.prompt)
try:
for event in stream:
if event.type == "delta":
answer += event.content
yield {
"type": "delta",
"content": event.content,
"request_id": event.request_id,
}
elif event.type == "done":
self._save_answer(prepared, answer, "complete")
saved = True
yield {"type": "done", "request_id": event.request_id}
except GeneratorExit:
stream.close()
if answer and not saved:
self._save_answer(prepared, answer, "stopped")
raise
except LLMGatewayError as exc:
if answer:
self._save_answer(prepared, answer, "error")
yield {
"type": "error",
"code": exc.code,
"message": str(exc),
"partial": exc.partial,
"request_id": prepared.call.request_id,
}
def _history(self, user_id: int, mentor_id: str, trade_date: str) -> list[dict[str, str]]:
with self._database.read() as connection:
rows = self._repository.messages(connection, user_id, mentor_id, trade_date, 40)
result = []
total = 0
for row in reversed(rows):
if str(row["status"]) != "complete":
continue
content = str(row["content"])
if total + len(content) > 24_000:
break
result.append({"role": str(row["role"]), "content": content})
total += len(content)
if len(result) == 10:
break
return list(reversed(result))
def _save_answer(self, prepared: PreparedChat, answer: str, status: str) -> None:
if not answer:
return
with self._database.transaction() as connection:
self._repository.add_message(
connection,
user_id=prepared.call.user_id,
mentor_id=prepared.skill.id,
trade_date=prepared.trade_date,
role="assistant",
content=answer,
request_id=prepared.call.request_id,
status=status,
created_at=_now(),
)
def _skill(self, principal: Principal, mentor_id: str) -> MentorSkill:
try:
return self._registry.get(mentor_id, principal.user.is_admin)
except MentorSkillError as exc:
raise MentorError(str(exc)) from exc
def _trade_date(self, requested_date: str) -> str:
requested = self._valid_date(requested_date)
return self._gateway.trade_context(requested).actual_date or requested
@staticmethod
def _valid_date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise MentorError("日期格式无效。") from exc
def _now() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PROFILE_IDS = {
"emotion": {
"kobe92-perspective",
"niepanchongsheng-perspective",
"chaojiyangjia-perspective",
"tuixuechaogu-perspective",
"chenxiaoqun-perspective",
"zhiyechaoshou-perspective",
},
"first_board": {
"beijingchaojia-perspective",
"chuangshiji-perspective",
"xuxiang-perspective",
"foshanwuyingjiao-perspective",
},
"leader": {
"zhaolaoge-perspective",
"fangxinxia-perspective",
"xiaoe-perspective",
"sunge-perspective",
"liuyizhonglu-perspective",
},
"trend": {
"zhangdetao-perspective",
"zhangmengzhu-perspective",
"zuoshouxinyi-perspective",
},
"low_absorption": {
"qiaobangzhu-perspective",
"asking-perspective",
"longfeihu-perspective",
"ruihexian-perspective",
},
"macro": {"shuipi-perspective"},
}
@dataclass(frozen=True, slots=True)
class MentorSkill:
id: str
name: str
description: str
tagline: str
focus: tuple[str, ...]
grade: str
evidence_label: str
evidence_note: str
profile: str
content: str
private: bool
def public(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"tagline": self.tagline,
"focus": list(self.focus),
"grade": self.grade,
"evidence_label": self.evidence_label,
"evidence_note": self.evidence_note,
"private": self.private,
}
class MentorSkillError(ValueError):
pass
class MentorSkillRegistry:
def __init__(self, public_root: Path, private_root: Path) -> None:
self._public_root = public_root
self._private_root = private_root
def list(self, include_private: bool) -> tuple[MentorSkill, ...]:
skills = {item.id: item for item in self._load_root(self._public_root, False)}
if include_private:
skills.update({item.id: item for item in self._load_root(self._private_root, True)})
return tuple(sorted(skills.values(), key=lambda item: (item.name.casefold(), item.id)))
def get(self, skill_id: str, include_private: bool) -> MentorSkill:
match = next((item for item in self.list(include_private) if item.id == skill_id), None)
if match is None:
raise MentorSkillError("思维模型不存在或当前账号不可见。")
return match
def _load_root(self, root: Path, private: bool) -> tuple[MentorSkill, ...]:
if not root.is_dir():
return ()
catalog = _catalog(root)
result = []
for directory in sorted(root.iterdir(), key=lambda item: item.name):
path = directory / "SKILL.md"
if directory.is_dir() and path.is_file():
result.append(_read_skill(path, catalog, private))
return tuple(result)
def _read_skill(path: Path, catalog: dict[str, Any], private: bool) -> MentorSkill:
if path.stat().st_size > 200_000:
raise MentorSkillError(f"Skill文件过大:{path.parent.name}")
content = path.read_text(encoding="utf-8")
metadata = _frontmatter(content)
raw_id = metadata.get("name") or path.parent.name
skill_id = re.sub(r"[^A-Za-z0-9_-]+", "-", raw_id).strip("-").lower()
if not skill_id:
raise MentorSkillError(f"Skill缺少有效ID{path.parent.name}")
heading = re.search(r"^#\s+(.+?)(?:\s*[·|]\s*.+)?$", content, re.MULTILINE)
name = heading.group(1).strip() if heading else path.parent.name
description_raw = metadata.get("description", "")
purpose = re.search(r"用途[:]\s*([^\n]+)", description_raw)
description = purpose.group(1).strip() if purpose else _first_sentence(description_raw)
tagline_match = re.search(r'^>\s*["“「](.+?)["”」]\s*$', content, re.MULTILINE)
focus = tuple(
item.strip()
for item in re.findall(r"^###\s+模型\d+[:]\s*(.+)$", content, re.MULTILINE)[:4]
)
item = catalog.get(skill_id) if isinstance(catalog.get(skill_id), dict) else {}
evidence = item.get("evidence") if isinstance(item.get("evidence"), dict) else {}
grade = str(evidence.get("grade") or "C").upper()
if grade not in {"A", "B", "C"}:
grade = "C"
return MentorSkill(
id=skill_id,
name=name.removesuffix("-perspective").strip(),
description=description,
tagline=tagline_match.group(1).strip() if tagline_match else "",
focus=focus,
grade=grade,
evidence_label=str(evidence.get("label") or "公开资料"),
evidence_note=str(evidence.get("note") or "素材等级待进一步核验"),
profile=_profile(skill_id, f"{description} {' '.join(focus)}"),
content=content,
private=private,
)
def _catalog(root: Path) -> dict[str, Any]:
path = root / "mentor_catalog.json"
if not path.is_file():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise MentorSkillError(f"思维模型目录无法读取:{root.name}") from exc
mentors = payload.get("mentors", payload) if isinstance(payload, dict) else {}
if not isinstance(mentors, dict):
raise MentorSkillError(f"思维模型目录格式错误:{root.name}")
return mentors
def _profile(skill_id: str, text: str) -> str:
for profile, identifiers in PROFILE_IDS.items():
if skill_id in identifiers:
return profile
keywords = (
("macro", ("宏观", "政策", "指数", "ETF")),
("trend", ("趋势", "资金面", "动能")),
("first_board", ("首板", "打板")),
("leader", ("龙头", "连板", "空间板")),
("low_absorption", ("低吸", "反包", "承接")),
("emotion", ("情绪", "周期", "退潮")),
)
return next(
(profile for profile, words in keywords if any(word in text for word in words)),
"emotion",
)
def _frontmatter(content: str) -> dict[str, str]:
if not content.startswith("---"):
return {}
end = content.find("\n---", 3)
if end < 0:
return {}
lines = content[3:end].strip().splitlines()
result: dict[str, str] = {}
index = 0
while index < len(lines):
line = lines[index]
if ":" not in line:
index += 1
continue
key, value = line.split(":", 1)
value = value.strip()
if value == "|":
block = []
index += 1
while index < len(lines) and (lines[index].startswith(" ") or not lines[index].strip()):
block.append(lines[index].strip())
index += 1
result[key.strip()] = "\n".join(block).strip()
continue
result[key.strip()] = value.strip('"\'')
index += 1
return result
def _first_sentence(value: str) -> str:
compact = " ".join(line.strip() for line in value.splitlines() if line.strip())
return re.split(r"[。;]", compact, maxsplit=1)[0].strip()