Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/en/development/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/development/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 读取当前范围,使用服务端周期与统计口径 |
Expand Down
30 changes: 28 additions & 2 deletions src/powercontext/server/dashboard/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"profile": ProfileContent,
}

SEARCH_LIMIT = 200


def segment(value: str) -> str:
return quote(value, safe="")
Expand Down Expand Up @@ -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:
Expand All @@ -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})

Expand Down
7 changes: 4 additions & 3 deletions src/powercontext/server/dashboard/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/powercontext/server/dashboard/labels.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/powercontext/server/dashboard/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "分页",
Expand Down
6 changes: 4 additions & 2 deletions src/powercontext/server/dashboard/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
**{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<input class="form-control" type="search" id="content-query" name="q" value="{{ search_query or '' }}" maxlength="{{ limit }}">
<button class="btn" type="submit">{{ t.search }}</button>
</div></div>
<div class="col-auto text-end"><a class="btn btn-ghost-secondary {% if not search_query %}invisible{% endif %}" href="{{ link(q=none, notes_page=none, notes_cursor=none, notes_history=none, skill_page=none, artifact=none, revision=none) }}" {% if not search_query %}tabindex="-1" aria-hidden="true"{% endif %}>{{ t.clear_search }}</a></div>
<div class="col-auto text-end"><a class="btn btn-ghost-secondary {% if not search_query %}invisible{% endif %}" href="{{ link(q=none, notes_page=none, notes_cursor=none, notes_history=none, skill_page=none, experience_page=none, experience_cursor=none, artifact=none, revision=none) }}" {% if not search_query %}tabindex="-1" aria-hidden="true"{% endif %}>{{ t.clear_search }}</a></div>
</div>
</form>
{% endmacro %}
6 changes: 3 additions & 3 deletions src/powercontext/server/dashboard/templates/methods.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
<nav class="nav nav-tabs mb-4" aria-label="{{ t.method_filter }}">
{% for value, label in [('experience', 'experience'), ('skill', 'skill_reading')] %}<a class="nav-link {% if method_kind == value %}active{% endif %}" href="{{ link('methods', kind=value, q=none, skill_page=none, experience_cursor=none, experience_history=none) }}" {% if method_kind == value %}aria-current="page"{% endif %}>{{ t[label] }}</a>{% endfor %}
</nav>
{% if method_kind == 'skill' %}{{ search_form(t.search_skills, 2000, 'skill') }}{% endif %}
{% if collection.get('search_limited') %}<p class="text-secondary">{{ t.skills_limit }}</p>{% endif %}
{{ search_form(t.search_experiences if method_kind == 'experience' else t.search_skills, 2000, method_kind) }}
{% if collection.get('search_limited') %}<p class="text-secondary">{{ t.experiences_limit if method_kind == 'experience' else t.skills_limit }}</p>{% endif %}
{% from 'components/read-error.html' import read_error with context %}
{% if errors.get(family) %}{{ read_error(errors[family], t[family]) }}{% endif %}
<section>
Expand All @@ -45,7 +45,7 @@ <h2 class="card-title fw-normal"><a class="text-reset d-block text-truncate" hre
<a href="{{ link(family, artifact=record.artifact_id, revision=record.revision) }}">{{ t.read_experience if family == 'experience' else t.read_skill }}</a>
</div></article>{% endif %}
{% endfor %}
{% if not data[family] and not errors.get(family) %}<section class="card collection-empty"><div class="empty">{% if search_query and family == 'skill' %}<h2>{{ t.skills_no_match }}</h2>{% else %}<h2>{{ t.experience_empty if family == 'experience' else t.skills_empty }}</h2>{% endif %}</div></section>{% endif %}
{% if not data[family] and not errors.get(family) %}<section class="card collection-empty"><div class="empty">{% if search_query %}<h2>{{ t.experiences_no_match if family == 'experience' else t.skills_no_match }}</h2>{% else %}<h2>{{ t.experience_empty if family == 'experience' else t.skills_empty }}</h2>{% endif %}</div></section>{% endif %}
</div>
<div class="collection-footer">{{ pagination(collection.get('pager'), t, always=family == 'skill') }}</div>
</section>
Expand Down
115 changes: 115 additions & 0 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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(
Expand Down