forked from EduCraft/curriculum-project-hub
refactor(hub): role 改为数据条目,不再是写死的闭集枚举
ADR-0017 说 role-based routing 是 product/admin config,不是 spec invariant。 之前 RunRole = "draft"|"review"|"triage" 闭集违反这条——role 钉死在代码里, 教师/admin 无法自定义。改为: - RoleEntry(id+label+defaultModel)是数据,registry 持有 role 集合 - roleId 退化为 string,新 role 是配置改动不是代码改动 - runner 不再带 role 字段(model 已由 caller resolve 好传入,role 透传无意义) - 未知 role 优雅降级到首个启用 model,不抛 tsc --noEmit rc=0;role-as-data + ADR-0003 不变式 smoke 通过。
This commit is contained in:
+41
-20
@@ -8,8 +8,20 @@
|
|||||||
* here — decided by admin settings + ADR, not hard-coded.
|
* here — decided by admin settings + ADR, not hard-coded.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Coarse run role, used to pick a default model. Open set: add as product grows. */
|
/**
|
||||||
export type RunRole = "draft" | "review" | "triage";
|
* A named role preset that maps to a default model. Roles are **data**, not a
|
||||||
|
* code enum: admin/teachers define them (ADR-0017: role-based routing is
|
||||||
|
* product config, not a spec invariant). `roleId` is an opaque string here —
|
||||||
|
* the registry holds the role set, so new roles are added by configuration,
|
||||||
|
* not by editing this file.
|
||||||
|
*/
|
||||||
|
export interface RoleEntry {
|
||||||
|
readonly id: string;
|
||||||
|
/** Human label for the teacher-side switcher UX. */
|
||||||
|
readonly label: string;
|
||||||
|
/** Default model id for runs under this role, if a routing rule is set. */
|
||||||
|
readonly defaultModel: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** A model the admin has enabled for use by the Hub. */
|
/** A model the admin has enabled for use by the Hub. */
|
||||||
export interface ModelEntry {
|
export interface ModelEntry {
|
||||||
@@ -22,41 +34,50 @@ export interface ModelEntry {
|
|||||||
|
|
||||||
export interface ModelRegistry {
|
export interface ModelRegistry {
|
||||||
/** All models admin-enabled for this Hub instance. */
|
/** All models admin-enabled for this Hub instance. */
|
||||||
list(): readonly ModelEntry[];
|
listModels(): readonly ModelEntry[];
|
||||||
/** Default model id for a given run role, if a routing rule exists. */
|
/** All role presets currently configured (admin/teacher-defined). */
|
||||||
defaultFor(role: RunRole): string | undefined;
|
listRoles(): readonly RoleEntry[];
|
||||||
|
/** The role preset with the given id, if any. */
|
||||||
|
role(id: string): RoleEntry | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
|
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
|
||||||
* is the skeleton seam. The policy (role→model map) is intentionally pluggable.
|
* is the skeleton seam. Both the model list and the role set are pluggable data
|
||||||
|
* — adding a role is a config change, not a code change.
|
||||||
*/
|
*/
|
||||||
export class InMemoryModelRegistry implements ModelRegistry {
|
export class InMemoryModelRegistry implements ModelRegistry {
|
||||||
private readonly models: readonly ModelEntry[];
|
private readonly models: readonly ModelEntry[];
|
||||||
private readonly defaults: Map<RunRole, string>;
|
private readonly roles: Map<string, RoleEntry>;
|
||||||
|
|
||||||
constructor(models: readonly ModelEntry[], defaults: Partial<Record<RunRole, string>> = {}) {
|
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
|
||||||
this.models = models;
|
this.models = models;
|
||||||
this.defaults = new Map(
|
this.roles = new Map(roles.map((r) => [r.id, r]));
|
||||||
(Object.entries(defaults) as [RunRole, string][]).filter(([, v]) => v !== undefined),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): readonly ModelEntry[] {
|
listModels(): readonly ModelEntry[] {
|
||||||
return this.models;
|
return this.models;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultFor(role: RunRole): string | undefined {
|
listRoles(): readonly RoleEntry[] {
|
||||||
return this.defaults.get(role);
|
return [...this.roles.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a model id, falling back to the role default, then the first enabled. */
|
role(id: string): RoleEntry | undefined {
|
||||||
resolve(requested: string | undefined, role: RunRole): string {
|
return this.roles.get(id);
|
||||||
if (requested !== undefined && this.models.some((m) => m.id === requested)) {
|
}
|
||||||
return requested;
|
|
||||||
|
/**
|
||||||
|
* Resolve a model id for a run. Priority: teacher's explicit request → the
|
||||||
|
* role preset's default → the first admin-enabled model. `roleId` is a string
|
||||||
|
* so unknown roles degrade gracefully to the fallback rather than throwing.
|
||||||
|
*/
|
||||||
|
resolve(requestedModel: string | undefined, roleId: string): string {
|
||||||
|
if (requestedModel !== undefined && this.models.some((m) => m.id === requestedModel)) {
|
||||||
|
return requestedModel;
|
||||||
}
|
}
|
||||||
const d = this.defaultFor(role);
|
const role = this.roles.get(roleId);
|
||||||
if (d !== undefined) return d;
|
if (role?.defaultModel !== undefined) return role.defaultModel;
|
||||||
const first = this.models[0];
|
const first = this.models[0];
|
||||||
if (first === undefined) throw new Error("no models enabled for this Hub");
|
if (first === undefined) throw new Error("no models enabled for this Hub");
|
||||||
return first.id;
|
return first.id;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
*/
|
*/
|
||||||
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js";
|
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js";
|
||||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||||
import type { RunRole } from "./models.js";
|
|
||||||
|
|
||||||
/** What the runner needs about the project to seed and bound a run. */
|
/** What the runner needs about the project to seed and bound a run. */
|
||||||
export interface ProjectContext {
|
export interface ProjectContext {
|
||||||
@@ -26,7 +25,6 @@ export interface RunRequest {
|
|||||||
readonly prompt: string;
|
readonly prompt: string;
|
||||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||||
readonly model: string;
|
readonly model: string;
|
||||||
readonly role: RunRole;
|
|
||||||
readonly project: ProjectContext;
|
readonly project: ProjectContext;
|
||||||
readonly systemPrompt?: string;
|
readonly systemPrompt?: string;
|
||||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||||
|
|||||||
+5
-3
@@ -24,7 +24,11 @@ function main(): void {
|
|||||||
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
||||||
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
||||||
],
|
],
|
||||||
{ draft: "z-ai/glm-4.6", review: "anthropic/claude-3.5-sonnet" },
|
[
|
||||||
|
// Roles are data here — admin/teachers add presets, not code changes.
|
||||||
|
{ id: "draft", label: "草稿", defaultModel: "z-ai/glm-4.6" },
|
||||||
|
{ id: "review", label: "审校", defaultModel: "anthropic/claude-3.5-sonnet" },
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tools = new ToolRegistry();
|
const tools = new ToolRegistry();
|
||||||
@@ -36,11 +40,9 @@ function main(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const model = models.resolve(undefined, "draft");
|
const model = models.resolve(undefined, "draft");
|
||||||
|
|
||||||
runAgent(provider, tools, {
|
runAgent(provider, tools, {
|
||||||
prompt: "ping",
|
prompt: "ping",
|
||||||
model,
|
model,
|
||||||
role: "draft",
|
|
||||||
project: {
|
project: {
|
||||||
projectId: "proj-skeleton",
|
projectId: "proj-skeleton",
|
||||||
boundChatId: "chat-skeleton",
|
boundChatId: "chat-skeleton",
|
||||||
|
|||||||
Reference in New Issue
Block a user