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.
|
||||
*/
|
||||
|
||||
/** 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. */
|
||||
export interface ModelEntry {
|
||||
@@ -22,41 +34,50 @@ export interface ModelEntry {
|
||||
|
||||
export interface ModelRegistry {
|
||||
/** All models admin-enabled for this Hub instance. */
|
||||
list(): readonly ModelEntry[];
|
||||
/** Default model id for a given run role, if a routing rule exists. */
|
||||
defaultFor(role: RunRole): string | undefined;
|
||||
listModels(): readonly ModelEntry[];
|
||||
/** All role presets currently configured (admin/teacher-defined). */
|
||||
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
|
||||
* 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 {
|
||||
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.defaults = new Map(
|
||||
(Object.entries(defaults) as [RunRole, string][]).filter(([, v]) => v !== undefined),
|
||||
);
|
||||
this.roles = new Map(roles.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
list(): readonly ModelEntry[] {
|
||||
listModels(): readonly ModelEntry[] {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
defaultFor(role: RunRole): string | undefined {
|
||||
return this.defaults.get(role);
|
||||
listRoles(): readonly RoleEntry[] {
|
||||
return [...this.roles.values()];
|
||||
}
|
||||
|
||||
/** Resolve a model id, falling back to the role default, then the first enabled. */
|
||||
resolve(requested: string | undefined, role: RunRole): string {
|
||||
if (requested !== undefined && this.models.some((m) => m.id === requested)) {
|
||||
return requested;
|
||||
role(id: string): RoleEntry | undefined {
|
||||
return this.roles.get(id);
|
||||
}
|
||||
const d = this.defaultFor(role);
|
||||
if (d !== undefined) return d;
|
||||
|
||||
/**
|
||||
* 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 role = this.roles.get(roleId);
|
||||
if (role?.defaultModel !== undefined) return role.defaultModel;
|
||||
const first = this.models[0];
|
||||
if (first === undefined) throw new Error("no models enabled for this Hub");
|
||||
return first.id;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.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. */
|
||||
export interface ProjectContext {
|
||||
@@ -26,7 +25,6 @@ export interface RunRequest {
|
||||
readonly prompt: string;
|
||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||
readonly model: string;
|
||||
readonly role: RunRole;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt?: string;
|
||||
/** 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: "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();
|
||||
@@ -36,11 +40,9 @@ function main(): void {
|
||||
);
|
||||
|
||||
const model = models.resolve(undefined, "draft");
|
||||
|
||||
runAgent(provider, tools, {
|
||||
prompt: "ping",
|
||||
model,
|
||||
role: "draft",
|
||||
project: {
|
||||
projectId: "proj-skeleton",
|
||||
boundChatId: "chat-skeleton",
|
||||
|
||||
Reference in New Issue
Block a user