diff --git a/docs/en/development/dashboard.md b/docs/en/development/dashboard.md index b0d431e3c4..8b63bfa591 100644 --- a/docs/en/development/dashboard.md +++ b/docs/en/development/dashboard.md @@ -134,7 +134,7 @@ The HTTP API specification defines request fields and limits. | 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 | | 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 479ab626c3..3e55e33e7d 100644 --- a/docs/zh/development/dashboard.md +++ b/docs/zh/development/dashboard.md @@ -134,7 +134,7 @@ Home 的记忆、交接、经验和技能可以独立阅读。一类内容缺失 | 范围选择 | `GET /v1/scopes`、`GET /v1/scopes/default`、`GET /v1/scopes/{scope_id}` | 默认值、显式选择、父子关系和可读范围分别处理 | | 记忆目录与正文 | `POST /v1/atomic-memory/list`、`POST /v1/memory/search`、Atomic Memory 精确版本与状态读取 | 保留独立制品身份、生命周期筛选、搜索模式与命中依据;最多 50 条匹配结果 | | 交接目录与正文 | `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/scripts/dashboard_browser.cjs b/scripts/dashboard_browser.cjs index 0908ae6465..290af5781a 100644 --- a/scripts/dashboard_browser.cjs +++ b/scripts/dashboard_browser.cjs @@ -782,14 +782,16 @@ async function main() { await page.waitForURL(url => url.searchParams.get('kind') === 'skill'); const skills = (await api('/v1/skill/library', { scope_id: readingScope })).skills; if (skills.length) { - await page.getByRole('searchbox').fill(skills[0].content.name); + const skillName = skills[0].content.name; + await page.getByRole('searchbox').fill(skillName); await page.getByRole('button', { name: 'Search', exact: true }).click(); - await page.waitForURL(url => url.searchParams.get('q') === skills[0].content.name); + await page.waitForURL(url => url.searchParams.get('q') === skillName); assert(await page.getByRole('navigation', { name: 'Pagination', exact: true }).isVisible()); - await page.getByRole('link', { name: skills[0].content.name, exact: true }).click(); + await page.getByRole('link', { name: skillName, exact: true }).click(); await page.waitForURL(url => url.pathname.endsWith('/skill')); await page.locator('.back-link').click(); - await page.waitForURL(url => url.pathname.endsWith('/methods') && url.searchParams.get('kind') === 'skill'); + await page.waitForURL(url => + url.pathname.endsWith('/methods') && url.searchParams.get('kind') === 'skill' && url.searchParams.get('q') === skillName); } await page.goto(base + '/dashboard/home'); await context.setOffline(true); diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py index d9cdf5c2c4..d9bcaad45b 100644 --- a/src/powercontext/server/dashboard/api.py +++ b/src/powercontext/server/dashboard/api.py @@ -38,6 +38,8 @@ from powercontext.server.dashboard.errors import ReadError as ReadError from powercontext.server.dashboard.session import authentication_headers +SEARCH_LIMIT = 200 + def segment(value: str) -> str: return quote(value, safe="") @@ -248,6 +250,22 @@ def skill_record(value: dict[str, Any]) -> dict[str, Any]: "artifacts": value["artifact_refs"], } + @staticmethod + def search_record(item: dict[str, Any]) -> dict[str, Any]: + """Adapt one family-scoped search hit to the Dashboard record shape.""" + + lineage = item["lineage"] + return { + **item["content"], + "artifact_id": item["artifact_id"], + "revision": item["revision"], + "sources": [ + {"source_type": source["source_type"], "source_id": source["source_id"]} + for source in lineage["sources"] + ], + "artifacts": lineage["artifacts"], + } + async def authorize_scope(self, scope: str, operation: str) -> None: from powercontext.server.app import _authorize_checks @@ -267,12 +285,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.skill_record(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.search_record(item) for item in result["results"]], + "next_cursor": None, + "search_limited": len(result["results"]) == SEARCH_LIMIT, } if self.builtin is None: url = httpx.URL(f"/v1/scopes/{segment(scope)}/artifacts/{family}", params={"limit": limit}) diff --git a/src/powercontext/server/dashboard/content.py b/src/powercontext/server/dashboard/content.py index cf62a17dc7..e1b0ee30a7 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 b060ba0d79..66b04d5c87 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 5e966a7598..14e95ef65b 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/navigation.py b/src/powercontext/server/dashboard/navigation.py index 063bb89560..bc157a38ea 100644 --- a/src/powercontext/server/dashboard/navigation.py +++ b/src/powercontext/server/dashboard/navigation.py @@ -24,6 +24,9 @@ COMMON = {"scope", "period", "lang", "theme"} HISTORY = {"view", "profile_cursor", "profile_history"} COLLECTION = {"cursor", "handoff_history"} +METHODS = {"kind", "q", "experience_page", "skill_page"} +# A 2,000-character query can expand to 24 KiB when percent encoded. +METHODS_RETURN_MAX = 32 * 1024 def positive_revision(raw: str | None) -> int: @@ -36,15 +39,19 @@ def _has_controls(value: str) -> bool: return any(ord(char) < 32 or ord(char) == 127 for char in value) -def _parameters(raw: str, maximum: int) -> tuple[str, dict[str, str]]: - if len(raw.encode()) > maximum or _has_controls(raw) or "\\" in raw: +def _parameters( + raw: str, maximum: int, *, backslash_values: frozenset[str] = frozenset() +) -> tuple[str, dict[str, str]]: + if len(raw.encode()) > maximum or _has_controls(raw): raise ValueError url = urlsplit(raw) - if url.scheme or url.netloc or url.fragment or not url.path.startswith("/dashboard/"): + if url.scheme or url.netloc or url.fragment or not url.path.startswith("/dashboard/") or "\\" in url.path: raise ValueError pairs = parse_qsl(url.query, keep_blank_values=True, max_num_fields=12, errors="strict") params = dict(pairs) - if len(params) != len(pairs) or any(_has_controls(value) or "\\" in value for value in params.values()): + if len(params) != len(pairs) or any( + _has_controls(value) or ("\\" in value and key not in backslash_values) for key, value in params.items() + ): raise ValueError scope = params.get("scope", "") if not scope.strip() or len(scope) > 256: @@ -73,24 +80,49 @@ def _validate_history(params: dict[str, str]) -> None: raise ValueError +def _validate_methods(params: dict[str, str]) -> None: + if params.get("kind", "experience") not in {"experience", "skill"}: + raise ValueError + for key in ("experience_page", "skill_page"): + if key in params and (not params[key].isascii() or not params[key].isdecimal() or int(params[key]) < 1): + raise ValueError + + def collection_return(raw: str | None, scope: str, *, page: str | None = None) -> str | None: if not raw: return None try: - path, params = _parameters(raw, 8192) + if page == "methods": + path, params = _parameters(raw, METHODS_RETURN_MAX, backslash_values=frozenset({"q"})) + else: + path, params = _parameters(raw, 8192) if params["scope"] != scope or (page and path != f"/dashboard/{page}"): return None - allowed = COMMON | COLLECTION if path == "/dashboard/handoff" else COMMON | HISTORY - if path not in {"/dashboard/handoff", "/dashboard/profile"} or params.keys() - allowed: - return None - if path == "/dashboard/profile" and params.get("view") != "history": - return None - _validate_history(params) + if page == "methods": + if params.keys() - (COMMON | METHODS): + return None + _validate_methods(params) + else: + allowed = COMMON | COLLECTION if path == "/dashboard/handoff" else COMMON | HISTORY + if path not in {"/dashboard/handoff", "/dashboard/profile"} or params.keys() - allowed: + return None + if path == "/dashboard/profile" and params.get("view") != "history": + return None + _validate_history(params) except (ValueError, UnicodeError): return None return path + "?" + urlencode(params) +def with_language(destination: str, language: str) -> str: + """Apply the current interface language to a validated return destination.""" + + url = urlsplit(destination) + params = dict(parse_qsl(url.query, keep_blank_values=True)) + params["lang"] = language + return url.path + "?" + urlencode(params) + + def _reading_fields(path: str, params: dict[str, str]) -> set[str]: if path == "/dashboard/profile": allowed = HISTORY diff --git a/src/powercontext/server/dashboard/pagination.py b/src/powercontext/server/dashboard/pagination.py index f8e5193c9a..b3b1631672 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/routes.py b/src/powercontext/server/dashboard/routes.py index f2f5a10b93..763bfe6c7c 100644 --- a/src/powercontext/server/dashboard/routes.py +++ b/src/powercontext/server/dashboard/routes.py @@ -34,6 +34,7 @@ directory_context, positive_revision, request_reading_return, + with_language, ) from powercontext.server.dashboard.page import load_builtin_page from powercontext.server.dashboard.pagination import PAGE_SIZE @@ -130,6 +131,7 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s "notes_history", "note_state", "skill_page", + "experience_page", "experience_history", "handoff_history", "resource_type", @@ -221,6 +223,7 @@ def reading_link_context( def initial_context(request: Request, page: str) -> dict[str, Any]: method_kind = request.query_params.get("kind", "experience") + return_page = "profile" if page == "profile" else "methods" if page in {"experience", "skill"} else "handoff" ctx: dict[str, Any] = { **presentation(request), "page": page, @@ -279,10 +282,12 @@ def initial_context(request: Request, page: str) -> dict[str, Any]: "return_to": collection_return( request.query_params.get("return_to"), request.query_params.get("scope", ""), - page="profile" if page == "profile" else "handoff", + page=return_page, ), "reading_return": request_reading_return(request), } + if ctx["return_to"]: + ctx["return_to"] = with_language(ctx["return_to"], ctx["language"]) ctx["link"] = links(request, ctx) return ctx @@ -469,6 +474,17 @@ async def rollback_revision(request: Request) -> Response: family = str(form.get("family") or "") artifact_id = str(form.get("artifact_id") or "") reason = str(form.get("reason") or "") + return_page = { + "experience": "methods", + "skill": "methods", + "handoff": "handoff", + "profile": "profile", + }.get(family) + return_to = ( + collection_return(str(form.get("return_to") or "") or None, scope, page=return_page) + if return_page is not None + else None + ) try: source_revision = positive_revision(str(form.get("source_revision") or "")) expected_revision = positive_revision(str(form.get("expected_revision") or "")) @@ -487,7 +503,9 @@ async def rollback_revision(request: Request) -> Response: ) except ReadError as error: code = "forbidden" if error.status == 403 else "rejected" - return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303) + return RedirectResponse( + _rollback_return(scope, family, artifact_id, source_revision, code, return_to), status_code=303 + ) finally: await api.client.aclose() if response.status_code == 412: @@ -498,12 +516,21 @@ async def rollback_revision(request: Request) -> Response: code = "rejected" else: created = response.json()["revision"] - return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None), status_code=303) - return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303) + return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None, return_to), status_code=303) + return RedirectResponse( + _rollback_return(scope, family, artifact_id, source_revision, code, return_to), status_code=303 + ) -def _rollback_return(scope: str, family: str, artifact_id: str, revision: int, error: str | None) -> str: - params: dict[str, Any] = {"scope": scope, "rollback_error": error} +def _rollback_return( + scope: str, + family: str, + artifact_id: str, + revision: int, + error: str | None, + return_to: str | None = None, +) -> str: + params: dict[str, Any] = {"scope": scope, "rollback_error": error, "return_to": return_to} if family == "profile": page = "profile" params["revision"] = revision diff --git a/src/powercontext/server/dashboard/templates/components/read-error.html b/src/powercontext/server/dashboard/templates/components/read-error.html index fa30d212a0..44fac54730 100644 --- a/src/powercontext/server/dashboard/templates/components/read-error.html +++ b/src/powercontext/server/dashboard/templates/components/read-error.html @@ -19,8 +19,8 @@

{{ label or t.error_heading }}

{{ t.get('error_' ~ error.status, t.error_503) }}

- {{ t.retry }} - {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page == 'handoff-detail' and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %} + {{ t.retry }} + {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page in ['handoff-detail', 'experience', 'skill'] and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %}
{% if error.request_id %}

{{ t.request_id }}: {{ error.request_id }}

{% endif %} diff --git a/src/powercontext/server/dashboard/templates/components/revision-history.html b/src/powercontext/server/dashboard/templates/components/revision-history.html index 93318febf0..0e26ef7df8 100644 --- a/src/powercontext/server/dashboard/templates/components/revision-history.html +++ b/src/powercontext/server/dashboard/templates/components/revision-history.html @@ -77,6 +77,7 @@

{{ row.name }}

{% if history.selected != history.head %}
+ {% if return_to %}{% endif %} {% if history.family == "profile" %}{% endif %} {% if history.family not in ["profile", "prompt", "topic-memory"] %} @@ -98,6 +99,7 @@

{{ row.name }}

{% if history.can_rollback and history.selected != history.head and not history.same_content %} + {% if return_to %}{% endif %} 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/experience.html b/src/powercontext/server/dashboard/templates/experience.html index ebafb1dacb..48887b787a 100644 --- a/src/powercontext/server/dashboard/templates/experience.html +++ b/src/powercontext/server/dashboard/templates/experience.html @@ -24,7 +24,7 @@
- ← {{ t.back_methods }} + ← {{ t.back_methods }} {{ t.experience }} · {{ data.title }}

{{ data.experience.lesson }}

diff --git a/src/powercontext/server/dashboard/templates/methods.html b/src/powercontext/server/dashboard/templates/methods.html index 5846a03983..bb3027c1cb 100644 --- a/src/powercontext/server/dashboard/templates/methods.html +++ b/src/powercontext/server/dashboard/templates/methods.html @@ -29,10 +29,10 @@ {% from 'components/search.html' import search_form with context %} {{ page_heading(t.methods, t.methods_subtitle) }} -{% 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 %}
@@ -40,12 +40,12 @@ {% for record in collection.get('items', []) %} {% if 'error' not in record %}{% 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/src/powercontext/server/dashboard/templates/skill.html b/src/powercontext/server/dashboard/templates/skill.html index 83f81cbed4..37846a440b 100644 --- a/src/powercontext/server/dashboard/templates/skill.html +++ b/src/powercontext/server/dashboard/templates/skill.html @@ -21,7 +21,7 @@ {% if data.skill %}
- ← {{ t.back_methods }} + ← {{ t.back_methods }}
{{ t.skill_reading }} · {{ data.title }} · {{ t.revision }} {{ data.skill.revision }}

{{ data.skill.name }}

{{ data.skill.description }}

{{ t.skill_instructions }}

{{ data.skill.instructions or t.skill_body_unavailable }}

diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b2c6f78dcb..a92de66bf5 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -20,7 +20,7 @@ from pathlib import Path from secrets import token_urlsafe from typing import Any, cast -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qs, urlencode, urlsplit import pytest from fastapi import FastAPI @@ -30,6 +30,10 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.tables import MODEL_USAGE_DAILY_TABLE +from powercontext.server.dashboard.api import DashboardAPI +from powercontext.server.dashboard.errors import ReadError +from powercontext.server.dashboard.navigation import collection_return +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 ( @@ -241,6 +245,78 @@ async def authorize(self, connection, scope_id, context, action, ref=None): dashboard.portal.call(injected.close) +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 create_skill(client: TestClient, scope: str, source: dict[str, Any], name: str) -> str: + proposal = { + "name": name, + "description": "Review the report", + "instructions": "Keep the documented limits.", + "validation": ["Keep the documented limits."], + } + candidate = client.post( + "/v1/skill/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 labelled_link(html: str, label: str) -> str: + """Return the first link whose text contains ``label`` (e.g. a back arrow).""" + + match = re.search(r']*href="([^"]+)"[^>]*>[^<]*' + re.escape(label) + r"[^<]*", html) + assert match is not None + return unescape(match[1]) + + +def detail_link(html: str, family: str) -> str: + return next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', html) + if urlsplit(unescape(value)).path == f"/dashboard/{family}" + ) + + +def alert_retry(html: str) -> str: + alert = re.search(r'', html, re.S) + assert alert is not None + match = re.search(r'href="([^"]+)"[^>]*>' + re.escape(LABELS["retry"]) + "", alert[0]) + assert match is not None + return unescape(match[1]) + + def test_scope_selection_default_and_unknown_scope(dashboard: TestClient) -> None: parent = create_scope(dashboard, "Project") child = create_scope(dashboard, "Documentation", parent["scope_id"]) @@ -598,6 +674,369 @@ 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_preserves_query_and_page_through_detail_and_back(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience search paging")["scope_id"] + source = capture_source(dashboard, scope, "Page through the matching experiences.") + for index in range(7): + create_experience( + dashboard, + scope, + source, + situation=f"Reviewing needle {index}", + lesson=f"Keep needle note {index} documented", + ) + first = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) + 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"] == ["needle"] + second = dashboard.get(following) + assert second.status_code == 200 + page_records = record_links(second.text, "/dashboard/experience", "artifact") + assert len(page_records) == 1 + detail_url = detail_link(second.text, "experience") + return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] + assert parse_qs(urlsplit(return_to).query)["q"] == ["needle"] + assert parse_qs(urlsplit(return_to).query)["experience_page"] == ["2"] + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + assert restored.url.params["experience_page"] == "2" + assert restored.url.params["kind"] == "experience" + assert record_links(restored.text, "/dashboard/experience", "artifact") == page_records + + +def test_skill_search_preserves_the_result_page_through_detail_and_back(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Skill search paging")["scope_id"] + source = capture_source(dashboard, scope, "Page through the matching skills.") + for index in range(7): + create_skill(dashboard, scope, source, f"needle-skill-{index}") + second = dashboard.get( + "/dashboard/methods", params={"scope": scope, "kind": "skill", "q": "needle", "skill_page": "2"} + ) + assert second.status_code == 200 + page_records = record_links(second.text, "/dashboard/skill", "artifact") + assert len(page_records) == 1 + detail_url = detail_link(second.text, "skill") + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + assert restored.url.params["skill_page"] == "2" + assert restored.url.params["kind"] == "skill" + assert record_links(restored.text, "/dashboard/skill", "artifact") == page_records + + +@pytest.mark.parametrize("family", ["experience", "skill"]) +def test_methods_return_uses_the_current_language(dashboard: TestClient, family: str) -> None: + scope = create_scope(dashboard, f"{family} language return")["scope_id"] + source = capture_source(dashboard, scope, f"Keep the {family} language searchable.") + if family == "experience": + create_experience(dashboard, scope, source, situation="needle situation", lesson="Keep the needle lesson") + else: + create_skill(dashboard, scope, source, "needle-skill") + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": family, "q": "needle", "lang": "en"}) + assert searched.status_code == 200 + detail_url = detail_link(searched.text, family) + return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] + assert parse_qs(urlsplit(return_to).query)["lang"] == ["en"] + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + switched = dashboard.get(f"{detail_url}&lang=zh") + assert switched.status_code == 200 + restored = dashboard.get(labelled_link(switched.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["lang"] == "zh" + assert restored.url.params["q"] == "needle" + assert restored.url.params["kind"] == family + assert ("搜索经验" if family == "experience" else "搜索技能") in restored.text + + +def test_methods_return_to_accepts_supported_text() -> None: + scope = "scp_demo" + for query in ("needle\\path", "needle & path = 100% #1", "中" * 2000): + raw = "/dashboard/methods?" + urlencode({ + "scope": scope, + "period": "7d", + "kind": "experience", + "q": query, + "experience_page": "2", + }) + result = collection_return(raw, scope, page="methods") + assert result is not None + params = parse_qs(urlsplit(result).query) + assert params["q"] == [query] + assert params["experience_page"] == ["2"] + + +@pytest.mark.parametrize( + "raw", + [ + "/dashboard/methods?scope=other&kind=experience&q=needle", + "/dashboard/handoff?scope=scp_demo&cursor=next", + "/dashboard/methods?scope=scp_demo&kind=all&q=needle", + "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&experience_page=1%5C2", + "/dashboard/methods?scope=scp_demo&kind=experience&q=needle%0D%0ASet-Cookie", + ], +) +def test_methods_return_to_rejects_unsupported_context(raw: str) -> None: + assert collection_return(raw, "scp_demo", page="methods") is None + + +def test_methods_return_to_rejects_oversized_context() -> None: + raw = "/dashboard/methods?scope=scp_demo&kind=experience&q=" + "a" * (33 * 1024) + assert collection_return(raw, "scp_demo", page="methods") is None + + +@pytest.mark.parametrize( + ("query", "text"), + [ + ("needle\\path", "Keep the needle path"), + ("needle & path = 100% #1", "Keep special characters"), + ("经验 " * 460, "经验 long query"), + ], + ids=["backslash", "special", "long"], +) +def test_experience_search_accepts_edge_query_text(dashboard: TestClient, query: str, text: str) -> None: + scope = create_scope(dashboard, "Experience edge queries")["scope_id"] + source = capture_source(dashboard, scope, "Keep edge queries searchable.") + create_experience(dashboard, scope, source, situation="needle path", lesson="Keep the needle path") + create_experience(dashboard, scope, source, situation="needle & path = 100% #1", lesson="Keep special characters") + create_experience(dashboard, scope, source, situation="经验 long query", lesson="经验 " * 460) + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": query}) + assert searched.status_code == 200 + assert text in searched.text + detail_url = detail_link(searched.text, "experience") + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == query + + +def test_experience_detail_rejects_a_foreign_return_to(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience return boundary")["scope_id"] + other = create_scope(dashboard, "Other experience return")["scope_id"] + source = capture_source(dashboard, scope, "Keep the return inside its scope.") + artifact = create_experience(dashboard, scope, source, situation="Reviewing", lesson="Stay in the scope") + detail = dashboard.get( + "/dashboard/experience", + params={ + "scope": scope, + "artifact": artifact, + "revision": "1", + "return_to": f"/dashboard/methods?scope={other}&kind=experience&q=needle", + }, + ) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["scope"] == scope + assert "q" not in restored.url.params + + +def test_experience_search_state_survives_compare_and_rollback(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience search history")["scope_id"] + content = { + "situation": "Reviewing the needle search", + "action": "Keep the search origin", + "outcome": "The result page survives detail navigation", + "lesson": "Keep the needle search state", + } + created = dashboard.post(f"/v1/scopes/{scope}/artifacts", json={"family": "experience", "content": content}) + assert created.status_code == 201, created.text + artifact = created.json()["artifact_id"] + current = dashboard.get(f"/v1/scopes/{scope}/artifacts/experience/{artifact}") + revised = dashboard.put( + f"/v1/scopes/{scope}/artifacts/experience/{artifact}", + json={"content": {**content, "lesson": "A later needle lesson"}}, + headers={"If-Match": current.headers["etag"]}, + ) + assert revised.status_code == 200, revised.text + + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) + assert searched.status_code == 200 + detail_url = detail_link(searched.text, "experience") + return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] + revision = dashboard.get( + "/dashboard/experience", + params={ + "scope": scope, + "artifact": artifact, + "revision": "1", + "compare": "2", + "return_to": return_to, + }, + ) + assert revision.status_code == 200, revision.text + assert 'action="/dashboard/rollback"' in revision.text + compare_form = re.search( + r'
.*?
', + revision.text, + re.S, + ) + assert compare_form is not None + assert 'name="return_to"' in compare_form[0] + rollback_form = re.search( + r'
.*?
', revision.text, re.S + ) + assert rollback_form is not None + assert 'name="return_to"' in rollback_form[0] + + restored = dashboard.post( + "/dashboard/rollback", + data={ + "scope": scope, + "family": "experience", + "artifact_id": artifact, + "source_revision": "1", + "expected_revision": "2", + "reason": "Restore the searched revision", + "return_to": return_to, + }, + headers={"Origin": "http://testserver"}, + ) + assert restored.status_code == 200, restored.text + assert restored.url.params["return_to"] == return_to + back = dashboard.get(labelled_link(restored.text, LABELS["back_methods"])) + assert back.status_code == 200 + assert back.url.params["q"] == "needle" + assert back.url.params["kind"] == "experience" + + +def test_experience_search_retry_keeps_the_result_page(dashboard: TestClient, monkeypatch) -> None: + scope = create_scope(dashboard, "Experience retry page")["scope_id"] + search_path = f"/v1/scopes/{scope}/artifacts/experience/search" + read = DashboardAPI.read + + async def failing(api, path, payload=None): + if path == search_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": "needle", "experience_page": "2"}, + ) + assert response.status_code == 200 + retry = alert_retry(response.text) + params = parse_qs(urlsplit(retry).query) + assert params["q"] == ["needle"] + assert params["experience_page"] == ["2"] + assert params["kind"] == ["experience"] + form = re.search(r'
', response.text, re.S) + assert form is not None + assert 'name="experience_page"' not in form[0] + + +def test_out_of_range_result_page_retry_returns_to_the_first_page(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience page range")["scope_id"] + source = capture_source(dashboard, scope, "Keep the page range searchable.") + create_experience(dashboard, scope, source, situation="needle range", lesson="Keep the needle range") + response = dashboard.get( + "/dashboard/methods", + params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "99"}, + ) + assert response.status_code == 200 + retry = alert_retry(response.text) + params = parse_qs(urlsplit(retry).query) + assert params["q"] == ["needle"] + assert "experience_page" not in params + restored = dashboard.get(retry) + assert restored.status_code == 200 + assert "Keep the needle range" in restored.text + + +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_experience_detail_error_returns_to_the_search_page(dashboard: TestClient, monkeypatch) -> None: + scope = create_scope(dashboard, "Experience detail error")["scope_id"] + source = capture_source(dashboard, scope, "Keep the needle error searchable.") + create_experience(dashboard, scope, source, situation="needle error", lesson="Keep the needle error") + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) + assert searched.status_code == 200 + detail_url = detail_link(searched.text, "experience") + read = DashboardAPI.read + + async def failing(api, path, payload=None): + if path.startswith(f"/v1/scopes/{scope}/artifacts/experience/"): + raise ReadError(503, "service_unavailable") + return await read(api, path, payload) + + monkeypatch.setattr(DashboardAPI, "read", failing) + detail = dashboard.get(detail_url) + assert detail.status_code == 503 + assert LABELS["error_503"] in detail.text + restored = dashboard.get(page_link(detail.text, LABELS["back_list"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + + def test_skill_with_usage_provenance_is_readable_and_searchable(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Skill review")["scope_id"] source = dashboard.post(