diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index 14de8f0..3ea6551 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -683,6 +683,14 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: def _resolve_project(account: dict[str, Any], project_id: str | None) -> dict[str, Any]: return legacy.resolve_target_project(account["id"], project_id or None, username=account["username"]) + def _resolve_project_for_read(account: dict[str, Any], project_id: str | None) -> dict[str, Any] | None: + if project_id: + return legacy.resolve_target_project(account["id"], project_id, username=account["username"]) + return legacy.db.fetch_one( + "SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC LIMIT 1", + (account["id"],), + ) + def _resolve_assistant(account: dict[str, Any], assistant_id: str | None, project_id: str = "") -> dict[str, Any] | None: return legacy.resolve_target_assistant(account["id"], assistant_id or None, project_id) @@ -4522,10 +4530,10 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: platform: str | None = Query(default=None), account: dict[str, Any] = Depends(legacy.require_approved), ) -> dict[str, Any]: - project = _resolve_project(account, project_id or None) + project = _resolve_project_for_read(account, project_id or None) return _effective_policy_payload( subject_account=account, - subject_project_id=project["id"], + subject_project_id=(project or {}).get("id", ""), platform=platform or "", ) @@ -4534,22 +4542,23 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: project_id: str | None = Query(default=None), account: dict[str, Any] = Depends(legacy.require_approved), ) -> dict[str, Any]: - project = _resolve_project(account, project_id or None) + project = _resolve_project_for_read(account, project_id or None) + resolved_project_id = (project or {}).get("id", "") scope_row = _policy_scope_row( scope_kind="user_global", subject_user_id=account["id"], - subject_project_id=project["id"], + subject_project_id=resolved_project_id, ) payload = _bundle_with_versions( scope_row, fallback_kind="user_global", fallback_user_id=account["id"], - fallback_project_id=project["id"], + fallback_project_id=resolved_project_id, active_version_only=True, ) payload["effective_policy"] = _effective_policy_payload( subject_account=account, - subject_project_id=project["id"], + subject_project_id=resolved_project_id, platform="", )["effective_policy"] return payload @@ -4600,11 +4609,11 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: project_id: str | None = Query(default=None), account: dict[str, Any] = Depends(legacy.require_approved), ) -> dict[str, Any]: - project = _resolve_project(account, project_id or None) + project = _resolve_project_for_read(account, project_id or None) scope_row = _policy_scope_row( scope_kind="user_global", subject_user_id=account["id"], - subject_project_id=project["id"], + subject_project_id=(project or {}).get("id", ""), ) items = _list_policy_versions(scope_row) return {"items": items, "count": len(items)} @@ -4616,13 +4625,14 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: limit: int = Query(default=20, ge=1, le=100), account: dict[str, Any] = Depends(legacy.require_approved), ) -> dict[str, Any]: - project = _resolve_project(account, project_id or None) + project = _resolve_project_for_read(account, project_id or None) + resolved_project_id = (project or {}).get("id", "") normalized_platform = _normalize_policy_platform(platform) where_clauses = [ "WHERE scope.subject_user_id = ?", "AND (scope.subject_project_id = ? OR scope.subject_project_id = '')", ] - params: list[Any] = [account["id"], project["id"]] + params: list[Any] = [account["id"], resolved_project_id] if normalized_platform: where_clauses.append("AND (scope.platform = '' OR scope.platform = ?)") params.append(normalized_platform) @@ -4670,12 +4680,13 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: project_id: str | None = Query(default=None), account: dict[str, Any] = Depends(legacy.require_approved), ) -> dict[str, Any]: - project = _resolve_project(account, project_id or None) + project = _resolve_project_for_read(account, project_id or None) + resolved_project_id = (project or {}).get("id", "") normalized_platform = _normalize_policy_platform(platform) scope_row = _policy_scope_row( scope_kind="user_platform", subject_user_id=account["id"], - subject_project_id=project["id"], + subject_project_id=resolved_project_id, platform=normalized_platform, ) payload = _bundle_with_versions( @@ -4683,12 +4694,12 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: fallback_kind="user_platform", fallback_platform=normalized_platform, fallback_user_id=account["id"], - fallback_project_id=project["id"], + fallback_project_id=resolved_project_id, active_version_only=True, ) payload["effective_policy"] = _effective_policy_payload( subject_account=account, - subject_project_id=project["id"], + subject_project_id=resolved_project_id, platform=normalized_platform, )["effective_policy"] return payload @@ -4743,12 +4754,12 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: project_id: str | None = Query(default=None), account: dict[str, Any] = Depends(legacy.require_approved), ) -> dict[str, Any]: - project = _resolve_project(account, project_id or None) + project = _resolve_project_for_read(account, project_id or None) normalized_platform = _normalize_policy_platform(platform) scope_row = _policy_scope_row( scope_kind="user_platform", subject_user_id=account["id"], - subject_project_id=project["id"], + subject_project_id=(project or {}).get("id", ""), platform=normalized_platform, ) items = _list_policy_versions(scope_row) diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 7cba927..ef4e071 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -130,6 +130,56 @@ class MainAgentGovernanceTests(unittest.TestCase): "member_headers": {"Authorization": f"Bearer {member_token}"}, } + def _seed_approved_member_without_project(self) -> dict[str, Any]: + now = self.db_module.utc_now() + admin_id = "acct_admin" + member_id = "acct_member_noproject" + model_id = "model_default" + admin_token = "token_admin" + member_token = "token_member_noproject" + + self.core.db.execute( + """ + INSERT INTO accounts ( + id, username, password_hash, password_salt, display_name, role, approval_status, + approved_by, approved_at, preferred_analysis_model_id, created_at, updated_at + ) VALUES (?, ?, 'hash', 'salt', ?, ?, 'approved', ?, ?, ?, ?, ?) + """, + (admin_id, "admin", "Admin", "super_admin", admin_id, now, model_id, now, now), + ) + self.core.db.execute( + """ + INSERT INTO accounts ( + id, username, password_hash, password_salt, display_name, role, approval_status, + approved_by, approved_at, preferred_analysis_model_id, created_at, updated_at + ) VALUES (?, ?, 'hash', 'salt', ?, ?, 'approved', ?, ?, ?, ?, ?) + """, + (member_id, "member_noproject", "Member No Project", "operator", admin_id, now, model_id, now, now), + ) + self.core.db.execute( + """ + INSERT INTO model_profiles ( + id, owner_account_id, name, provider, base_url, api_key, model_name, + is_system, is_default, created_at, updated_at + ) VALUES (?, NULL, 'Default Model', 'openai_compat', 'http://127.0.0.1:8317/v1', '', 'GLM-5', 1, 1, ?, ?) + """, + (model_id, now, now), + ) + self.core.db.execute( + "INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)", + (admin_token, admin_id, now), + ) + self.core.db.execute( + "INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)", + (member_token, member_id, now), + ) + return { + "admin_id": admin_id, + "member_id": member_id, + "admin_headers": {"Authorization": f"Bearer {admin_token}"}, + "member_headers": {"Authorization": f"Bearer {member_token}"}, + } + def test_effective_policy_merges_system_user_global_and_platform_layers(self) -> None: system_response = self.client.put( "/v2/admin/oneliner/governance/system/main-agent", @@ -378,6 +428,33 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(system_read.status_code, 200, system_read.text) self.assertEqual(system_read.json()["current_version"]["title"], "System baseline") + def test_governance_read_endpoints_do_not_create_default_project_when_project_is_missing(self) -> None: + self._clear_tables() + ctx = self._seed_approved_member_without_project() + before_count = self.core.db.fetch_one("SELECT COUNT(*) AS count FROM projects WHERE user_id = ?", (ctx["member_id"],)) + self.assertEqual(int((before_count or {}).get("count") or 0), 0) + + effective_response = self.client.get( + "/v2/oneliner/governance/effective", + headers=ctx["member_headers"], + params={"platform": "douyin"}, + ) + self.assertEqual(effective_response.status_code, 200, effective_response.text) + effective_payload = effective_response.json() + self.assertEqual(effective_payload["project_id"], "") + self.assertEqual(effective_payload["layers"], []) + + global_response = self.client.get( + "/v2/oneliner/governance/user/global", + headers=ctx["member_headers"], + ) + self.assertEqual(global_response.status_code, 200, global_response.text) + self.assertEqual(global_response.json()["scope"]["subject_project_id"], "") + self.assertIsNone(global_response.json()["current_version"]) + + after_count = self.core.db.fetch_one("SELECT COUNT(*) AS count FROM projects WHERE user_id = ?", (ctx["member_id"],)) + self.assertEqual(int((after_count or {}).get("count") or 0), 0) + def test_admin_governance_directory_lists_accounts_and_projects(self) -> None: response = self.client.get( "/v2/admin/oneliner/governance/directory", diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index df53df4..7bc4de5 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -965,6 +965,7 @@ function renderOneLinerMessagesHtml() { const result = message.result || {}; const plan = message.plan || {}; const executionCard = result.execution_card || {}; + const activeAdminOverrideNotice = executionCard.active_admin_override_notice || null; const actions = safeArray(plan.suggested_actions); const secondaryActions = safeArray(executionCard.secondary_actions); return ` @@ -995,6 +996,16 @@ function renderOneLinerMessagesHtml() { ${executionCard.readiness_label ? `= 50 ? "blue" : "orange"}">${escapeHtml(executionCard.readiness_label)} ${escapeHtml(formatNumber(executionCard.readiness_score || 0))}` : ""} ${executionCard.primary_action?.key ? `${escapeHtml(executionCard.primary_action.label || "执行下一步")}` : ""} + ${activeAdminOverrideNotice?.title ? ` +
${escapeHtml(activeAdminOverrideNotice.summary || "当前这轮执行会优先遵循管理员覆盖,再叠加你的个人策略。")}
+ +${escapeHtml(activeAdminOverrideNotice.summary || "当前主 Agent 会优先遵循管理员覆盖层。")}
+ +${escapeHtml(activeAdminOverrideNotice.summary || activeAdminOverrideNotice.title || "当前这层管理员覆盖会优先于你的个人策略生效。")}
+ +${escapeHtml(activeAdminOverrideNotice.summary || "当前 OneLiner 和平台 Agent 都会先遵循管理员覆盖层。")}
+ +${escapeHtml(project?.name || "当前项目")} · ${escapeHtml(platformLabel(platform))}。这里展示系统默认、你的个性化策略和管理员覆盖是如何叠加生效的。
+ ${activeAdminOverrideNotice?.title ? ` +${escapeHtml(activeAdminOverrideNotice.summary || "当前项目下的部分策略被管理员覆盖层托底。")}
+ +