feat: 推荐问题配置支持按标签搜索并在列表展示标签
- 后端列表接口为每个推荐问题附带 tags,并支持 tag_ids OR 过滤 - 前端配置页新增 TagSearchBar(标题/内容搜索 + 标签筛选),卡片展示标签 - 打标签后失效 recommended-questions 查询,列表即时刷新 - 过滤时禁用拖拽排序,避免破坏 sort_order Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5182ea5d11
commit
97da7dc87f
6 files changed
+161
-7
No files matched your search
@@ -399,6 +399,11 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label
|
||||
changes: `GET /api/recommended-questions` is now
|
||||
filtered by the caller's position (`?all=1` bypasses for admin management) and
|
||||
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`.
|
||||
Each listed question now carries its assigned `tags` (bulk-attached via
|
||||
`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids`
|
||||
OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by
|
||||
them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests:
|
||||
`tests/test_recommended_questions_tags.py`.
|
||||
`GET /api/skills` no longer restricts by position; each skill carries a
|
||||
`favorited` flag (true = in the caller's 我的技能, e.g. position-granted) so the
|
||||
skills page's 我的 tab includes owned ∪ position-granted skills while the 广场
|
||||
|
||||
@@ -27,6 +27,7 @@ class RecommendedQuestionResponse(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
sort_order: int = 0
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this question (按岗位分发标签)")
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
@@ -62,6 +63,7 @@ def _get_store(request: Request):
|
||||
async def list_recommended_questions(
|
||||
request: Request,
|
||||
all: bool = Query(default=False, description="Admin management view: return every question, unfiltered by 岗位."),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep questions carrying any of these tags (OR)."),
|
||||
) -> RecommendedQuestionsListResponse:
|
||||
store = _get_store(request)
|
||||
rows = await store.list_all()
|
||||
@@ -78,7 +80,26 @@ async def list_recommended_questions(
|
||||
visible = None
|
||||
if visible is not None:
|
||||
rows = [r for r in rows if r["id"] in visible]
|
||||
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
|
||||
responses = [RecommendedQuestionResponse(**r) for r in rows]
|
||||
|
||||
# Bulk-attach the tags assigned to each question so the management list can
|
||||
# display them and filter by 标签. Best-effort: a missing/failing tag store
|
||||
# never breaks the question list.
|
||||
tag_store = getattr(request.app.state, "tag_store", None)
|
||||
if tag_store is not None and responses:
|
||||
try:
|
||||
assignments = await tag_store.list_assignments("recommended_question", [r.id for r in responses])
|
||||
except Exception:
|
||||
logger.debug("recommended-question tag attach failed", exc_info=True)
|
||||
assignments = {}
|
||||
for resp in responses:
|
||||
resp.tags = assignments.get(resp.id, [])
|
||||
|
||||
if tag_ids:
|
||||
wanted = set(tag_ids)
|
||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||
|
||||
return RecommendedQuestionsListResponse(questions=responses)
|
||||
|
||||
|
||||
@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Router-level tests for recommended-question tag embedding + filtering.
|
||||
|
||||
The list endpoint coroutine is invoked directly with a stub request whose
|
||||
``app.state`` carries a fake question store and an in-memory tag store, mirroring
|
||||
the lightweight router tests already in this suite (see ``test_tags_api.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.routers import recommended_questions as rq_router
|
||||
from deerflow.persistence.tags import MemoryTagStore
|
||||
|
||||
|
||||
class _FakeQuestionStore:
|
||||
def __init__(self, rows: list[dict]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
async def list_all(self) -> list[dict]:
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
def _request(question_store, tag_store) -> SimpleNamespace:
|
||||
state = SimpleNamespace(
|
||||
user=None,
|
||||
recommended_question_store=question_store,
|
||||
tag_store=tag_store,
|
||||
position_sync=None,
|
||||
)
|
||||
return SimpleNamespace(state=state, app=SimpleNamespace(state=state))
|
||||
|
||||
|
||||
def _rows() -> list[dict]:
|
||||
return [
|
||||
{"id": "q1", "title": "市场分析", "content": "分析一下市场", "sort_order": 0},
|
||||
{"id": "q2", "title": "理赔报告", "content": "生成理赔报告", "sort_order": 1},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_embeds_assigned_tags() -> None:
|
||||
tag_store = MemoryTagStore()
|
||||
tag = await tag_store.create_tag(
|
||||
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
|
||||
)
|
||||
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
|
||||
|
||||
req = _request(_FakeQuestionStore(_rows()), tag_store)
|
||||
resp = await rq_router.list_recommended_questions(req, all=True)
|
||||
|
||||
by_id = {q.id: q for q in resp.questions}
|
||||
assert by_id["q1"].tags == []
|
||||
assert [t["name"] for t in by_id["q2"].tags] == ["理赔"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_tag_ids() -> None:
|
||||
tag_store = MemoryTagStore()
|
||||
tag = await tag_store.create_tag(
|
||||
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
|
||||
)
|
||||
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
|
||||
|
||||
req = _request(_FakeQuestionStore(_rows()), tag_store)
|
||||
resp = await rq_router.list_recommended_questions(req, all=True, tag_ids=[tag["id"]])
|
||||
|
||||
assert [q.id for q in resp.questions] == ["q2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_without_tag_store_does_not_break() -> None:
|
||||
req = _request(_FakeQuestionStore(_rows()), None)
|
||||
resp = await rq_router.list_recommended_questions(req, all=True)
|
||||
|
||||
assert [q.id for q in resp.questions] == ["q1", "q2"]
|
||||
assert all(q.tags == [] for q in resp.questions)
|
||||
Reference in new issue
Block a user