From ce2bd6e72997835892b43ce4395b9c8e396d167e Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:07:59 +0800 Subject: [PATCH] feat(dashboard): add text search to the Experience list The Methods page lets users search Skills but only browse Experiences, even though the family-scoped Artifact search added in #1862 covers the family. Load the Experience collection through POST /v1/scopes/{scope_id}/artifacts/experience/search when the Methods page carries a query, and adapt each hit to the exact-revision record shape the existing card, detail and paging path already uses. An empty query keeps the cursor-based browsing, and the result window follows the Skill search so a broad query cannot render hundreds of cards at once. Page links keep the kind and the query, the clear action resets the search position, and no-match and read failures stay separate with matching Chinese and English labels. Document the search entry point on the Dashboard page-API table. --- docs/en/development/dashboard.md | 2 +- docs/zh/development/dashboard.md | 2 +- src/powercontext/server/dashboard/api.py | 30 ++++- src/powercontext/server/dashboard/content.py | 7 +- .../server/dashboard/labels.en.json | 3 + src/powercontext/server/dashboard/labels.json | 3 + .../server/dashboard/pagination.py | 6 +- .../templates/components/search.html | 2 +- .../server/dashboard/templates/methods.html | 6 +- tests/test_dashboard.py | 115 ++++++++++++++++++ 10 files changed, 163 insertions(+), 13 deletions(-) diff --git a/docs/en/development/dashboard.md b/docs/en/development/dashboard.md index 4c54ac32e3..cf1338624f 100644 --- a/docs/en/development/dashboard.md +++ b/docs/en/development/dashboard.md @@ -159,7 +159,7 @@ Use this section to locate the implementation. The code and API specification de | Scope selection | `GET /v1/scopes`, `GET /v1/scopes/default`, `GET /v1/scopes/{scope_id}` | Treat defaults, explicit selection, hierarchy and readable scopes separately | | Memory collection and text | `POST /v1/atomic-memory/list`, `POST /v1/memory/search`, exact Atomic Memory revision and state reads | Preserve Atomic Memory identities, lifecycle filters, the selected search mode and hit evidence; return up to 50 matches. The search compatibility route resolves `auto` and maps `fts` to Atomic Memory `text` | | Handoff collection and text | `GET /v1/scopes/{scope_id}/artifacts/handoff` and exact revision reads | Retain cursors; list order does not imply chronological order | -| Experience collection and text | `GET /v1/scopes/{scope_id}/artifacts/experience`, `POST /v1/experience/get` | Dashboard provides paged browsing; see [Artifact search](../docs/workflows/search-artifacts.md) for Family relevance retrieval | +| Experience collection and text | `GET /v1/scopes/{scope_id}/artifacts/experience`, `POST /v1/experience/get`, `POST /v1/scopes/{scope_id}/artifacts/experience/search` | Dashboard provides paged browsing and Family relevance search; search covers approved active revisions only, returns up to 200 matches, and suggests a narrower query at that limit | | Skill collection and text | `POST /v1/skill/library`, `POST /v1/skill/get` | Library queries return up to 200 entries; suggest a narrower query at the limit and preserve source identities | | Source material | `GET /v1/scopes/{scope_id}/sources/{source_type}/{source_id}` | Verify the source's relationship to the record; `content` is currently the source type with readable text | | Usage | `POST /v1/stats` | Read the current scope with `exact` and use Server reporting periods and statistical definitions | diff --git a/docs/zh/development/dashboard.md b/docs/zh/development/dashboard.md index 349cf80ed2..ac1c70f14c 100644 --- a/docs/zh/development/dashboard.md +++ b/docs/zh/development/dashboard.md @@ -158,7 +158,7 @@ Jinja2 组织内容与页面结构,HTMX 处理导航和片段替换。只有 | 范围选择 | `GET /v1/scopes`、`GET /v1/scopes/default`、`GET /v1/scopes/{scope_id}` | 默认值、显式选择、父子关系和可读范围分别处理 | | 记忆目录与正文 | `POST /v1/atomic-memory/list`、`POST /v1/memory/search`、Atomic Memory 精确版本与状态读取 | 保留独立制品身份、生命周期筛选、搜索模式与命中依据;最多 50 条匹配结果。搜索兼容入口解析 `auto`,并把 `fts` 映射为 Atomic Memory 的 `text` | | 交接目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/handoff` 及精确版本读取 | 保留游标;列表顺序不解释为时间顺序 | -| 经验目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/experience`、`POST /v1/experience/get` | Dashboard 提供分页浏览;Family 相关性检索见 [Artifact 检索](../docs/workflows/search-artifacts.md) | +| 经验目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/experience`、`POST /v1/experience/get`、`POST /v1/scopes/{scope_id}/artifacts/experience/search` | Dashboard 提供分页浏览与 Family 相关性搜索;搜索只覆盖 approved 且 active 的当前版本,最多返回 200 条,达到上限时提示缩小查询 | | 技能目录与正文 | `POST /v1/skill/library`、`POST /v1/skill/get` | 库检索最多 200 项,达到上限时提示缩小查询;保留来源身份 | | 原始材料 | `GET /v1/scopes/{scope_id}/sources/{source_type}/{source_id}` | 验证材料与记录的关联;当前可读取正文的来源类型为 `content` | | 用量 | `POST /v1/stats` | 以 `exact` 读取当前范围,使用服务端周期与统计口径 | diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py index 6a51f66283..4b00a95627 100644 --- a/src/powercontext/server/dashboard/api.py +++ b/src/powercontext/server/dashboard/api.py @@ -47,6 +47,8 @@ "profile": ProfileContent, } +SEARCH_LIMIT = 200 + def segment(value: str) -> str: return quote(value, safe="") @@ -154,12 +156,22 @@ async def records( self, scope: str, family: str, *, cursor: str | None = None, limit: int = 12, query: str | None = None ) -> dict[str, Any]: if family == "skill": - maximum = 1 if limit == 1 else 200 + maximum = 1 if limit == 1 else SEARCH_LIMIT result = await self.read("/v1/skill/library", {"scope_id": scope, "limit": maximum, "query": query}) return { "items": [self.family_record(family, item) for item in result["skills"]], "next_cursor": None, - "search_limited": maximum == 200 and len(result["skills"]) == maximum, + "search_limited": maximum == SEARCH_LIMIT and len(result["skills"]) == maximum, + } + if family == "experience" and query: + result = await self.read( + f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/search", + {"query": query, "limit": SEARCH_LIMIT}, + ) + return { + "items": [self.family_record(family, self.search_record(item)) for item in result["results"]], + "next_cursor": None, + "search_limited": len(result["results"]) == SEARCH_LIMIT, } url = httpx.URL(f"/v1/scopes/{segment(scope)}/artifacts/{family}", params={"limit": limit}) if cursor: @@ -179,6 +191,20 @@ async def load(item: dict[str, Any]) -> dict[str, Any]: "next_cursor": page["next_cursor"], } + @staticmethod + def search_record(item: dict[str, Any]) -> dict[str, Any]: + """Adapt one family-scoped search hit to the exact-revision record shape.""" + + lineage = item["lineage"] + return { + "content": item["content"], + "artifact": {"artifact_id": item["artifact_id"], "revision": item["revision"]}, + "source_refs": [ + {"name": source["source_type"], "source_id": source["source_id"]} for source in lineage["sources"] + ], + "artifact_refs": lineage["artifacts"], + } + async def topic_memory_search(self, scope: str, query: str) -> dict[str, Any]: return await self.read("/v1/topic-memory/search", {"scope_id": scope, "query": query}) diff --git a/src/powercontext/server/dashboard/content.py b/src/powercontext/server/dashboard/content.py index e72deb4257..6755c62b40 100644 --- a/src/powercontext/server/dashboard/content.py +++ b/src/powercontext/server/dashboard/content.py @@ -34,16 +34,17 @@ async def load_collection(api: DashboardAPI, request: Request, ctx: dict[str, An ctx["collection_page_size"] = PAGE_SIZE try: cursor = request.query_params.get(f"{family}_cursor" if page == "methods" else "cursor") + query = ctx["search_query"] if page == "methods" else None result = await api.records( scope, family, cursor=cursor, limit=1 if page == "home" else PAGE_SIZE, - query=ctx["search_query"] if family == "skill" and page == "methods" else None, + query=query, ) if page != "home": - if family == "skill": - window = list_page(result["items"], request.query_params.get("skill_page")) + if family == "skill" or query: + window = list_page(result["items"], request.query_params.get(f"{family}_page")) result["items"] = window["items"] result["pager"] = list_links(ctx, family, window) else: diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json index 8b045c4a12..271f5d6712 100644 --- a/src/powercontext/server/dashboard/labels.en.json +++ b/src/powercontext/server/dashboard/labels.en.json @@ -224,8 +224,11 @@ "light": "Light", "dark": "Dark", "display_preferences": "Display Settings", + "search_experiences": "Search experiences", "search_skills": "Search skills", "search": "Search", + "experiences_limit": "Showing the first 200 experiences. Search to narrow the results.", + "experiences_no_match": "No matching experiences", "skills_limit": "Showing the first 200 skills. Search to narrow the results.", "skills_no_match": "No matching skills", "pagination": "Pagination", diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json index 0eea5445dc..80876b9349 100644 --- a/src/powercontext/server/dashboard/labels.json +++ b/src/powercontext/server/dashboard/labels.json @@ -224,8 +224,11 @@ "light": "浅色", "dark": "深色", "display_preferences": "显示设置", + "search_experiences": "搜索经验", "search_skills": "搜索技能", "search": "搜索", + "experiences_limit": "显示前 200 条经验,可通过搜索缩小结果范围。", + "experiences_no_match": "没有匹配的经验", "skills_limit": "显示前 200 项技能,可通过搜索缩小结果范围。", "skills_no_match": "没有匹配的技能", "pagination": "分页", diff --git a/src/powercontext/server/dashboard/pagination.py b/src/powercontext/server/dashboard/pagination.py index d2d0eea5d9..f3e98aa60e 100644 --- a/src/powercontext/server/dashboard/pagination.py +++ b/src/powercontext/server/dashboard/pagination.py @@ -46,8 +46,10 @@ def list_links(ctx: dict[str, Any], family: str, window: dict[str, Any]) -> dict params: dict[str, Any] = {} if family == "notes": params.update(artifact=None, revision=None, notes_cursor=None, notes_history=None) - if family == "skill": - params["kind"] = "skill" + if family in {"skill", "experience"}: + params["kind"] = family + if family == "experience": + params.update(experience_cursor=None, experience_history=None) return { "page": window["page"], **{ diff --git a/src/powercontext/server/dashboard/templates/components/search.html b/src/powercontext/server/dashboard/templates/components/search.html index ed4c913db4..a00b0f9375 100644 --- a/src/powercontext/server/dashboard/templates/components/search.html +++ b/src/powercontext/server/dashboard/templates/components/search.html @@ -31,7 +31,7 @@ -
+
{% endmacro %} diff --git a/src/powercontext/server/dashboard/templates/methods.html b/src/powercontext/server/dashboard/templates/methods.html index 5846a03983..adff9f8134 100644 --- a/src/powercontext/server/dashboard/templates/methods.html +++ b/src/powercontext/server/dashboard/templates/methods.html @@ -31,8 +31,8 @@ -{% if method_kind == 'skill' %}{{ search_form(t.search_skills, 2000, 'skill') }}{% endif %} -{% if collection.get('search_limited') %}

{{ t.skills_limit }}

{% endif %} +{{ search_form(t.search_experiences if method_kind == 'experience' else t.search_skills, 2000, method_kind) }} +{% if collection.get('search_limited') %}

{{ t.experiences_limit if method_kind == 'experience' else t.skills_limit }}

{% endif %} {% from 'components/read-error.html' import read_error with context %} {% if errors.get(family) %}{{ read_error(errors[family], t[family]) }}{% endif %}
@@ -45,7 +45,7 @@

{{ t.read_experience if family == 'experience' else t.read_skill }} {% endif %} {% endfor %} - {% if not data[family] and not errors.get(family) %}
{% if search_query and family == 'skill' %}

{{ t.skills_no_match }}

{% else %}

{{ t.experience_empty if family == 'experience' else t.skills_empty }}

{% endif %}
{% endif %} + {% if not data[family] and not errors.get(family) %}
{% if search_query %}

{{ t.experiences_no_match if family == 'experience' else t.skills_no_match }}

{% else %}

{{ t.experience_empty if family == 'experience' else t.skills_empty }}

{% endif %}
{% endif %}

diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 0feb8440a5..c9640127e5 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -26,6 +26,9 @@ from pydantic import SecretStr from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.dashboard.api import DashboardAPI +from powercontext.server.dashboard.errors import ReadError +from powercontext.server.dashboard.preferences import CATALOGS from powercontext.server.dashboard.routes import LABELS from powercontext.server.factory import create_server_app from powercontext.server.settings import ( @@ -121,6 +124,33 @@ def create_scope(client: TestClient, title: str, parent: str | None = None) -> d return result.json() +def capture_source(client: TestClient, scope: str, content: str) -> dict[str, Any]: + result = client.post("/v1/sources/content", json={"scope_id": scope, "source_id": "evidence", "content": content}) + assert result.is_success + return result.json()["source"] + + +def create_experience(client: TestClient, scope: str, source: dict[str, Any], situation: str, lesson: str) -> str: + proposal = { + "situation": situation, + "action": "Reuse the artifact search API", + "outcome": "Matching experiences return", + "lesson": lesson, + } + candidate = client.post( + "/v1/experience/propose", + json={"scope_id": scope, "proposal": proposal, "source_refs": [source], "artifact_refs": []}, + ) + assert candidate.status_code == 201 + body = candidate.json() + approved = client.post( + "/v1/candidates/approve", + json={"scope_id": scope, "candidate_id": body["candidate_id"], "expected_version": body["version"]}, + ) + assert approved.status_code == 200 + return approved.json()["result_artifact"]["artifact_id"] + + def test_scope_selection_default_and_unknown_scope(dashboard: TestClient) -> None: parent = create_scope(dashboard, "Project") child = create_scope(dashboard, "Documentation", parent["scope_id"]) @@ -432,6 +462,91 @@ def test_method_pagination_returns_to_the_previous_records(dashboard: TestClient assert collect_pages(dashboard, first.text, f"/dashboard/{family}", "artifact") == expected +@pytest.mark.parametrize("language", ["zh", "en"]) +def test_experience_search_filters_results_and_restores_browsing(dashboard: TestClient, language: str) -> None: + labels = CATALOGS[language] + scope = create_scope(dashboard, f"Experience search {language}")["scope_id"] + source = capture_source(dashboard, scope, "Search the dashboard experiences.") + create_experience( + dashboard, scope, source, situation="Adding dashboard search", lesson="Deploy the dashboard search form" + ) + create_experience( + dashboard, scope, source, situation="Rotating the signing key", lesson="Never reuse a retired signing key" + ) + + browsing = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "lang": language}) + assert browsing.status_code == 200 + assert labels["search_experiences"] in browsing.text + assert "Deploy the dashboard search form" in browsing.text + assert "Never reuse a retired signing key" in browsing.text + + searched = dashboard.get( + "/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "dashboard", "lang": language} + ) + assert searched.status_code == 200 + assert 'value="dashboard"' in searched.text + assert "Deploy the dashboard search form" in searched.text + assert "Never reuse a retired signing key" not in searched.text + + cleared = dashboard.get(page_link(searched.text, labels["clear_search"])) + assert cleared.status_code == 200 + assert cleared.url.params["kind"] == "experience" + assert "Never reuse a retired signing key" in cleared.text + assert 'value="dashboard"' not in cleared.text + + empty = dashboard.get( + "/dashboard/methods", + params={"scope": scope, "kind": "experience", "q": "absent-token", "lang": language}, + ) + assert empty.status_code == 200 + assert labels["experiences_no_match"] in empty.text + assert labels["experience_empty"] not in empty.text + + +def test_experience_search_keeps_the_query_across_result_pages(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience search paging")["scope_id"] + source = capture_source(dashboard, scope, "Page through the matching experiences.") + expected = { + create_experience( + dashboard, + scope, + source, + situation=f"Reviewing handoff {index}", + lesson=f"Keep handoff note {index} documented", + ) + for index in range(7) + } + + first = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "handoff"}) + assert first.status_code == 200 + assert len(record_links(first.text, "/dashboard/experience", "artifact")) == 6 + following = page_link(first.text, LABELS["next_page"]) + assert parse_qs(urlsplit(following).query)["q"] == ["handoff"] + assert collect_pages(dashboard, first.text, "/dashboard/experience", "artifact") == expected + + +def test_experience_search_reports_request_failures_distinctly(dashboard: TestClient, monkeypatch) -> None: + scope = create_scope(dashboard, "Experience search failure")["scope_id"] + source = capture_source(dashboard, scope, "Search failures stay separate from empty results.") + create_experience(dashboard, scope, source, situation="Losing the search backend", lesson="Surface a read failure") + read = DashboardAPI.read + search_path = f"/v1/scopes/{scope}/artifacts/experience/search" + seen: list[str] = [] + + async def failing(api, path, payload=None): + if path == search_path: + seen.append(path) + raise ReadError(503, "service_unavailable") + return await read(api, path, payload) + + monkeypatch.setattr(DashboardAPI, "read", failing) + response = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "search"}) + assert response.status_code == 200 + assert seen == [search_path] + assert LABELS["error_503"] in response.text + assert LABELS["experiences_no_match"] not in response.text + + def test_skill_with_usage_provenance_is_readable_and_searchable(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Skill review")["scope_id"] source = dashboard.post(