Skip to content

Commit b249424

Browse files
authored
fix(ai): add assistant tool guardrails (#1066)
1 parent e1dc289 commit b249424

14 files changed

Lines changed: 545 additions & 114 deletions

feeluown/ai/copilot.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@
99
from feeluown.app import App
1010
from feeluown.ai.llm import create_chat_model_with_config
1111
from feeluown.ai.matcher import SongSuggestionMatcher
12+
from feeluown.ai.model_cache import ModelCache
1213
from feeluown.ai.models import SongSuggestion
1314
from feeluown.ai.tools import copilot_tools
14-
from feeluown.library import BriefSongModel, SimpleSearchResult
15+
from feeluown.library import (
16+
BaseModel,
17+
BriefSongModel,
18+
SimpleSearchResult,
19+
)
1520
from feeluown.utils.dispatch import Signal
1621

1722

@@ -99,26 +104,32 @@ class CopilotContext:
99104
100105
通用规则:
101106
- 当你向用户推荐或整理一组歌曲时,优先调用 create_song_suggestions_artifact 工具创建可交互歌曲建议列表。
107+
- SongSuggestion 是尚未匹配成 SongModel 的歌曲建议;不要把一组 SongSuggestion 一次性转换或播放。
108+
- create_song_suggestions_artifact 会清洗并校验歌曲建议;单个 artifact 最多包含 20 首。
109+
- play_song_suggestion 只用于“最新用户消息明确要求播放某一首建议歌曲”的场景。
110+
- 对 SongSuggestion artifact 中的歌曲,如果用户要求播放,先用 library_search 找到 SongModel。
111+
- 播放真实歌曲资源时,调用 play_song_by_uri,传入歌曲 uri。
102112
- 上一首、下一首、暂停、继续、停止、音量调整等基础播放控制,应通过 playback_ 开头的工具完成。
103113
- 当用户要求搜索在线音乐资源时,优先使用 library_search 工具,并用 timeout 控制最长等待时间。
104114
- library_search 返回的 data.results 中的 uri 是真实资源 URI,可以在 Markdown 链接里使用。
105115
- library_search 会创建搜索结果 artifact,并在 data.artifact_id 返回编号。
106-
- 当用户要求播放搜索结果中的某首歌时,使用歌曲的 artifact_song_position 调用 play_artifact_song。
107116
108117
AI 电台:
109118
- AI 电台开关、状态和偏好应优先通过 ai_radio_ 开头的工具完成,不要要求用户去其它界面操作。
110119
- AI 电台只是激活 FM 模式的一种方式;FeelUOwn 也可以通过歌曲电台等其它方式进入 FM 模式。
111-
- 当用户要求开启、启动、进入 AI 电台时,调用 ai_radio_activate。
120+
- 当用户明确要求开启、启动、进入 AI 电台时,才调用 ai_radio_activate。
112121
- 当用户要求关闭、停止、退出 AI 电台时,调用 ai_radio_deactivate。
113122
- 当用户反馈会影响后续 AI 电台推荐偏好时,调用 ai_radio_update_preferences。
123+
- 在执行依赖 AI 电台已开启的操作前,先调用 ai_radio_get_state;如果返回 inactive,不要自动开启,除非最新用户消息明确要求开启 AI 电台。
114124
115125
FM 候选列表:
116126
- FM 候选歌曲指播放列表中当前播放歌曲后面的真实歌曲。
117127
- FM 候选歌曲不是 SongSuggestion,也不是正文中的 fuo://song-suggestion 链接。
118128
- FM 候选列表和 AI 电台是否开启无直接关系。
119129
- 查看 FM 候选列表时调用 fm_candidates_get_state。
120130
- 修改 FM 候选列表时只使用 fm_candidates_remove 和 fm_candidates_append。
121-
- fm_candidates_append 接收真实 provider 歌曲。
131+
- fm_candidates_append 接收真实歌曲 URI 列表。
132+
- fm_candidates_append 一次最多追加 3 首真实歌曲;更多歌曲需要分批处理。
122133
- 如果只有文字描述,先调用 library_search 找到真实歌曲资源。
123134
- 清空候选列表时,先调用 fm_candidates_get_state,再用 fm_candidates_remove 删除全部候选位置。
124135
- 替换候选列表时,先 remove 不需要的候选,再 append 新候选。
@@ -170,6 +181,7 @@ def __init__(self, app: App):
170181
self._agent_context = CopilotContext(copilot=self, app=app)
171182
self._agent_stream_callback = AgentStreamCallback(self)
172183
self._artifacts = ArtifactsManager()
184+
self._model_cache = ModelCache(getattr(app, "library", None))
173185
self.artifact_added = self._artifacts.added
174186
self._current_thread_id = 1
175187
# Agent is working or not
@@ -190,6 +202,7 @@ def is_working(self, working: bool):
190202
def new_thread(self):
191203
self._current_thread_id += 1
192204
self._artifacts.clear()
205+
self._model_cache = ModelCache(getattr(self._app, "library", None))
193206

194207
async def match_song_suggestion(
195208
self, suggestion: SongSuggestion
@@ -214,7 +227,22 @@ def add_songs_artifact(
214227
def add_search_result_artifact(
215228
self, results: List[SimpleSearchResult], title: str = ""
216229
) -> CopilotArtifact:
217-
return self._artifacts.add_search_result(results, title=title)
230+
artifact = self._artifacts.add_search_result(results, title=title)
231+
for song in artifact.songs:
232+
if not isinstance(song, SongSuggestion):
233+
self.cache_model(song)
234+
return artifact
235+
236+
def cache_model(self, model: BaseModel):
237+
self._model_cache.set_model(model)
238+
239+
def get_model_by_uri(self, uri: str) -> BaseModel:
240+
return self._model_cache.get(uri)
241+
242+
def get_song_by_uri(self, uri: str) -> BriefSongModel:
243+
model = self.get_model_by_uri(uri)
244+
assert isinstance(model, BriefSongModel)
245+
return model
218246

219247
def get_artifacts(self) -> List[CopilotArtifact]:
220248
return self._artifacts.list()

feeluown/ai/model_cache.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from collections import OrderedDict
2+
from threading import Lock
3+
4+
from feeluown.library import BaseModel, ModelType, parse_line, reverse
5+
6+
7+
def parse_model_uri(uri: str) -> BaseModel:
8+
uri = uri.strip()
9+
if not uri:
10+
raise ValueError("model URI is required")
11+
try:
12+
model, path = parse_line(uri)
13+
except Exception as e:
14+
raise ValueError("invalid model URI") from e
15+
if path:
16+
raise ValueError("model URI must not include path")
17+
return model
18+
19+
20+
class ModelCache:
21+
"""Session scoped cache for resolving model URI to model objects."""
22+
23+
def __init__(self, library, maxsize: int = 256):
24+
if maxsize < 0:
25+
raise ValueError("maxsize must not be negative")
26+
self._library = library
27+
self._maxsize = maxsize
28+
self._models: OrderedDict[str, BaseModel] = OrderedDict()
29+
self._lock = Lock()
30+
31+
def set_model(self, model: BaseModel):
32+
with self._lock:
33+
self._set(reverse(model), model)
34+
35+
def get(self, uri: str) -> BaseModel:
36+
model = parse_model_uri(uri)
37+
cache_key = reverse(model)
38+
39+
with self._lock:
40+
cached_model = self._models.get(cache_key)
41+
if cached_model is not None:
42+
self._models.move_to_end(cache_key)
43+
return cached_model
44+
45+
if self._library is None:
46+
raise RuntimeError("library is required on cache miss")
47+
fetched_model = self._library.model_get(
48+
model.source,
49+
ModelType(model.meta.model_type),
50+
model.identifier,
51+
)
52+
53+
with self._lock:
54+
cached_model = self._models.get(cache_key)
55+
if cached_model is not None:
56+
self._models.move_to_end(cache_key)
57+
return cached_model
58+
self._set(cache_key, fetched_model)
59+
return fetched_model
60+
61+
def _set(self, uri: str, model: BaseModel):
62+
self._models[uri] = model
63+
self._models.move_to_end(uri)
64+
while len(self._models) > self._maxsize:
65+
self._models.popitem(last=False)

feeluown/ai/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
@dataclass
77
class SongSuggestion:
8-
"""A song suggested by the AI before it is matched to a provider song.
8+
"""A song suggested by the AI before it is matched to a SongModel.
99
1010
:param description: Recommendation reason or song description.
1111
"""

feeluown/ai/tools/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from feeluown.ai.tools.library import library_search, library_tools
2-
from feeluown.ai.tools.artifacts import play_artifact_song, artifact_tools
2+
from feeluown.ai.tools.songs import play_song_by_uri, song_tools
33
from feeluown.ai.tools.playback import (
44
playback_adjust_volume,
55
playback_get_state,
@@ -35,7 +35,7 @@
3535
copilot_tools = [
3636
*suggestion_tools,
3737
*library_tools,
38-
*artifact_tools,
38+
*song_tools,
3939
*playback_tools,
4040
*ai_radio_tools,
4141
*fm_candidates_tools,
@@ -56,9 +56,9 @@
5656
"fm_candidates_tools",
5757
"library_search",
5858
"library_tools",
59-
"artifact_tools",
59+
"song_tools",
6060
"play_song_suggestion",
61-
"play_artifact_song",
61+
"play_song_by_uri",
6262
"playback_adjust_volume",
6363
"playback_get_state",
6464
"playback_next_track",

feeluown/ai/tools/ai_radio.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ def _ai_radio_unavailable_result():
2929

3030

3131
@tool
32-
def ai_radio_activate(runtime: ToolRuntime, reset: bool = True) -> dict:
32+
def ai_radio_activate(
33+
runtime: ToolRuntime,
34+
reset: bool = True,
35+
) -> dict:
3336
"""Activate AI Radio.
3437
3538
This switches the playlist into FM mode and lets the current AI radio

feeluown/ai/tools/artifacts.py

Lines changed: 0 additions & 62 deletions
This file was deleted.

feeluown/ai/tools/fm_candidates.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from langchain.tools import tool, ToolRuntime
22

3-
from feeluown.ai.tools.result import tool_bool_result, tool_success
3+
from feeluown.ai.tools.result import tool_bool_result, tool_error, tool_success
44
from feeluown.library import BriefSongModel
55
from feeluown.player.playlist import PlaylistMode
66
from feeluown.serializers import serialize
77

88

9+
MAX_APPEND_SONGS = 3
10+
11+
912
def _get_fm_candidates(runtime: ToolRuntime):
1013
return runtime.context.app.fm.candidates
1114

@@ -67,15 +70,60 @@ def fm_candidates_remove(positions: list[int], runtime: ToolRuntime) -> dict:
6770

6871
@tool
6972
def fm_candidates_append(
70-
songs: list[BriefSongModel], runtime: ToolRuntime
73+
song_uris: list[str], runtime: ToolRuntime
7174
) -> dict:
72-
"""Append real songs to the FM candidate list.
75+
"""Append songs to the FM candidate list by SongModel URI.
76+
77+
FM candidates are SongModel items. Use library_search first when you need
78+
to discover SongModel URIs from text.
7379
74-
FM candidates are real provider songs. Use library_search first when you
75-
need to discover real provider songs from text.
80+
Append at most 3 songs in one call. When adding more songs, split the work
81+
into smaller batches so matching/searching remains observable and bounded.
7682
77-
:param songs: Real provider songs to append.
83+
:param song_uris: SongModel URI list to append.
7884
"""
85+
if len(song_uris) > MAX_APPEND_SONGS:
86+
return tool_error(
87+
"fm_candidates_append",
88+
"TOO_MANY_SONGS",
89+
"Append at most 3 songs in one fm_candidates_append call.",
90+
data={
91+
"success": False,
92+
"max_song_count": MAX_APPEND_SONGS,
93+
"song_count": len(song_uris),
94+
"active": _is_fm_active(runtime),
95+
},
96+
)
97+
if not _is_fm_active(runtime):
98+
return _fm_candidate_result("fm_candidates_append", False, runtime)
99+
100+
songs = []
101+
for uri in song_uris:
102+
try:
103+
song = runtime.context.copilot.get_song_by_uri(uri)
104+
except ValueError:
105+
return tool_error(
106+
"fm_candidates_append",
107+
"INVALID_SONG_URI",
108+
"A valid SongModel URI is required.",
109+
data={
110+
"success": False,
111+
"song_uri": uri,
112+
"active": _is_fm_active(runtime),
113+
},
114+
)
115+
except Exception: # noqa
116+
return tool_error(
117+
"fm_candidates_append",
118+
"SONG_MODEL_NOT_FOUND",
119+
"SongModel was not found for the given URI.",
120+
data={
121+
"success": False,
122+
"song_uri": uri,
123+
"active": _is_fm_active(runtime),
124+
},
125+
)
126+
songs.append(song)
79127
fm_candidates = _get_fm_candidates(runtime)
80128
return _fm_candidate_result(
81129
"fm_candidates_append",

0 commit comments

Comments
 (0)