All other collection fields in this file are declared `readonly` to enforce immutability of the shared config, but `options` is a mutable `Array`. This inconsistency allows accidental mutation of the config (e.g., push/sort in callers). Declare it `readonly` for consistency.

options?: ReadonlyArray<{ label: string; value: string }>;

options?: Array<{ label: string; value: string }>;


The `key` values here (e.g. 'overview', 'student') are plain string literals with no compile-time linkage to the `skillKey` used by actual tool definitions (see `ToolDef.skillKey` in agent-tool.types.ts). Since `AgentSkillDescriptor.key` is typed as `string`, a typo or a key that no tool implements will silently produce an empty skill at runtime. Consider using a shared union type / const assertion (e.g. `as const` + `satisfies`) so keys are validated against the set of defined tools.

export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
  {
    key: 'overview',


The description hardcodes the three workflow keys (`student_teaching / dormitory_billing / classroom_rental`), duplicating `BUSINESS_WORKFLOW_KEYS` which is already imported and used to build the schema enum dynamically. When a new workflow is added to the registry, the enum updates automatically but this LLM-facing description silently drifts out of sync. Consider composing the description from `BUSINESS_WORKFLOW_KEYS` to keep them in lockstep.

  readonly description =
    `获取当前账号可见范围内的业务待办计数（未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等），可按闭环（${BUSINESS_WORKFLOW_KEYS.join(' / ')}）聚焦。写入或导入前用它核实前置数据是否齐备，完成后用它判断后续待办。`;

  readonly description =
    '获取当前账号可见范围内的业务待办计数（未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等）。写入或导入前用它核实前置数据是否齐备，完成后用它判断后续待办。';


`workflowKey` is matched with exact equality against the enum, but LLM-supplied input can easily carry accidental whitespace (e.g. `"student_teaching "`). Such input is rejected with the confusing "必须是 X 之一" error even though the value is semantically valid. Other tools normalize string input via `optionalString` (which trims); consider trimming here before the `includes` check.

    if (
      typeof input.workflowKey !== 'string' ||
      !BUSINESS_WORKFLOW_KEYS.includes(input.workflowKey.trim())
    ) {
      return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };
    }
    return { ok: true, value: { workflowKey: input.workflowKey.trim() } };

    if (
      typeof input.workflowKey !== 'string' ||
      !(BUSINESS_WORKFLOW_KEYS).includes(input.workflowKey)
    ) {
      return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };
    }
    return { ok: true, value: { workflowKey: input.workflowKey } };


Inconsistent returned context: entities are collected from the visible workflows' stage.entities, but then additionally filtered by the entity's own requiredPermissions. If a visible workflow references an entity the principal lacks permission for (e.g. a user with `student:view` but not `attendance:view`), the workflow is still returned while the entity definition is dropped. The caller then receives workflows whose `stages[].entities` reference keys absent from the returned `entities` array — and the entity key is already leaked via the workflow anyway. Either return all referenced entities for visible workflows, or drop workflows that reference entities the principal cannot view, so the result is internally consistent.

    const entities = BUSINESS_ENTITIES.filter(
      (entity) => entityKeys.has(entity.key) && hasPermission(principal, entity.requiredPermissions),
    );
    return { workflows, entities };


validateRegistry is incomplete as an integrity check: it detects duplicate entity keys, duplicate field keys, and duplicate stage keys within a workflow, but not duplicate workflow keys across BUSINESS_WORKFLOWS (a duplicate key would make getBusinessContext(principal, workflowKey) silently return multiple workflows) nor duplicate nextSteps step keys (which would make suggestNextSteps return duplicate suggestions). Add checks for both so the registry validator catches these.

    const duplicateEntities = BUSINESS_ENTITIES
      .map((entity) => entity.key)
      .filter((key, index, all) => all.indexOf(key) !== index);


`stageKeys` here is a union of stage keys across ALL workflows, so a relation's `requiredFor` reference passes validation as long as the key exists in any workflow — e.g. 'schedule' is a stage in both student_teaching and classroom_rental, and 'profile'/'room' also appear in multiple workflows. This lets cross-workflow stage-key collisions mask a wrong reference (a relation pointing at a stage that doesn't exist in the workflow context where the entity is actually used). Consider validating `requiredFor` against the per-workflow stage keys of the workflow(s) that use this entity.

        for (const stageKey of relation.requiredFor) {
          if (!stageKeys.has(stageKey)) {
            problems.push(`实体 ${entity.key} 关系 ${relation.via} 引用未知阶段: ${stageKey}`);
          }
        }


Description hardcodes business keys (student_teaching / dormitory_billing / classroom_rental) and the entity list that already live in business-context.registry.ts. If a workflow or entity key is renamed/removed in the registry, this LLM-facing prompt silently drifts and the model will keep generating stale keys. Consider composing the description (or the inputSchema description) from BUSINESS_WORKFLOWS/BUSINESS_ENTITIES so keys stay in sync automatically.

'获取当前账号可见的业务流程、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。';

'获取当前账号可见的业务流程（学生教学/住宿计费/教室租赁）、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。';


The synchronous service call is wrapped in Promise.resolve instead of using async/await. Since both service methods are synchronous, making these handlers `async execute(...)` and returning the value directly is simpler, clearer, and matches the project's preference for async/await.

  async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
    return this.service.getBusinessContext(

  execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
    return Promise.resolve(


The error message 'entityKey 必须是字符串' is misleading: this branch also catches empty/whitespace-only strings and strings longer than 50 chars (before trim). A caller passing an empty string gets a message claiming the type is wrong. Split the checks so each failure returns an accurate message (e.g. 'entityKey 不能为空' for empty/whitespace-only, 'entityKey 长度不能超过 50' for length).

    if (typeof input.entityKey !== 'string') {
      return { ok: false, error: 'entityKey 必须是字符串' };
    }
    if (!input.entityKey.trim()) {
      return { ok: false, error: 'entityKey 不能为空' };
    }

    if (typeof input.entityKey !== 'string' || !input.entityKey.trim()) {
      return { ok: false, error: 'entityKey 必须是字符串' };
    }


Unknown keys are silently accepted: a typo'd workflowKey yields an empty workflow list and a typo'd entityKey yields null, both as 'success'. For an LLM agent this is a silent failure that wastes a turn and may make the model conclude the data does not exist. Consider validating the key against the registry and returning an explicit error for unknown keys (while still returning empty/null for known-but-forbidden keys to avoid leaking existence).

const workflowKey = optionalString(input.workflowKey, 'workflowKey', 50);


The permission subject string `PermissionCode:${tool.requiredPermission}` is hand-built here, duplicating the exported helper `permissionCodeSubject()` from `../authorization/casl.constants` (which the executor's execute-time check goes through via `AuthorizationService.canPermission`). If the subject format ever changes, this listing filter and the execute-time authorization check will silently diverge — a tool could disappear from listings or, worse, be listed without matching the execute-time gate. Use the shared helper instead to keep a single source of truth. (The explicit super-admin branch is also redundant: a `manage all` ability already satisfies `ability.can(Access, ...)` in CASL, but keeping it is harmless.)


`register()` silently replaces an existing tool that shares the same `name`. Since the doc comment states it is "called once at module init", a duplicate name almost certainly indicates a configuration error (two tools defined with the same name), and silently overwriting the earlier registration makes that error invisible — the first tool is dropped with no warning and only the last one ever runs. Consider throwing (or at least logging a warning) on duplicate names so misconfiguration is caught at startup rather than at runtime.

    if (this.tools.some((t) => t.name === tool.name)) {
      throw new Error(`Agent tool already registered: ${tool.name}`);
    }
    this.tools.push(tool);

    const idx = this.tools.findIndex((t) => t.name === tool.name);
    if (idx >= 0) {
      this.tools[idx] = tool;
    } else {
      this.tools.push(tool);
    }


Dead branch for non-super-admins: `mapPermissionCode`/`permissionToAction` in casl.constants.ts only ever produces create/read/update/delete domain rules — `manage` is granted solely by `createForUser` when `isSuperAdmin` is true. So for any regular user this method is effectively `isSuperAdmin || can(Update, Class)`. A user holding `attendance:edit` (mapped to `update Attendance`) or any `attendance:manage`-style exact code will be denied here, and `get-attendance-summary.tool.ts` will silently fall back to class-scoped data instead of all-attendance scope. If full attendance management should be honored, check `ability.can(CaslAction.Update, SubjectName.Attendance)` (or use `CaslAction.Access` + `permissionCodeSubject('attendance:edit')`); otherwise drop the misleading `Manage` branch.

  canManageAllAttendance(context: AgentToolContext): boolean {
    const ability = this.ability(context);
    return (
      context.isSuperAdmin ||
      ability.can(CaslAction.Manage, SubjectName.Attendance) ||
      ability.can(CaslAction.Update, SubjectName.Class)
    );
  }


Same dead `Manage` branch: `dashboard:view` maps to `read Dashboard` (never `manage Dashboard`), so for non-super-admins `canManageAllDashboard` reduces to `isSuperAdmin || can(Update, Class)`. A user holding only `dashboard:view` (no `class:edit`) is treated as not managing all and `get-dashboard-stats.tool.ts` returns class-scoped data instead of school-wide stats. If `dashboard:view` should grant full scope, check `ability.can(CaslAction.Read, SubjectName.Dashboard)` or the exact permission code; otherwise remove the `Manage` branch.

  canManageAllDashboard(context: AgentToolContext): boolean {
    const ability = this.ability(context);
    return (
      context.isSuperAdmin ||
      ability.can(CaslAction.Manage, SubjectName.Dashboard) ||
      ability.can(CaslAction.Update, SubjectName.Class)
    );
  }


Duplicated logic: `context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class)` is repeated verbatim in all three methods, and each call rebuilds a fresh CASL ability via `createForUser`. Extract a shared private helper (e.g. `private canManageClasses(context: AgentToolContext): boolean`) and reuse it in the attendance/dashboard checks; this also makes the intended semantics (class-scope granting) explicit and avoids rebuilding the ability if several scope checks run in one tool invocation.

  canManageAllClasses(context: AgentToolContext): boolean {
    const ability = this.ability(context);
    return context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class);
  }


Attendance metadata is internally inconsistent: the description says records are by 班级与日期 (class + date), and the relations claim attendance links to class/schedule via 'class_schedule', but the entity has no classId field — only studentId/date/status. Since fields drive render_form, the agent cannot generate a class selector for attendance, and the class_schedule 'via' is the class↔schedule join table, not an attendance link. Suggest adding a classId field (and/or a dedicated via) so the form can capture the class the record belongs to.

      field('studentId', '学生', 'number', { required: true }),
      field('date', '日期', 'date', { required: true }),
      field('status', '状态', 'enum', { enumFrom: 'attendance.status' }),


This relation's requiredFor references stage 'profile', but the 'profile' stage only exists in the student_teaching and dormitory_billing workflows. The 'organization' entity is only used by the classroom_rental workflow, whose stages are classroom/organization/rental/contract/schedule — so 'profile' can never be satisfied in the workflow where this relation is actually relevant. Additionally, the counterpart relation is missing on the 'student' entity (student has relations to class/occupancy/bill/exam but not organization). Since validateRegistry checks requiredFor against the union of ALL workflow stage keys, this cross-workflow reference silently passes validation. This is likely a misplaced relation: it should either live on the student entity (e.g., relation('organization', 'organization', ['profile'])) or reference a stage within classroom_rental.


Stage keys are not unique across workflows: 'profile' (student_teaching + dormitory_billing) and 'schedule' (student_teaching + classroom_rental) are duplicated. Because BusinessContextService.validateRegistry builds one global set from all workflows' stage keys, relation.requiredFor / nextSteps.after references are validated only against the union, so a relation in one workflow can silently reference a stage belonging to another workflow (see the organization entity's requiredFor ['profile']). Consider namespacing stage keys (e.g., 'teaching:profile', 'rental:schedule') or validating requiredFor against the workflow that actually uses the entity.

{ key: 'schedule', label: '教室日程', entities: ['schedule', 'classroom', 'rental'] },


The `LEFT JOIN bills b ON b.student_id = o.student_id` matches any bill the student has *ever* had, with no status or period filter. Since `bills` is generated per student per period (`period_start`/`period_end`) and includes historical statuses (`paid`, `cancelled`), a student whose current active occupancy simply has no bill *yet* for the current period will be excluded from this count as long as they have any older (even cancelled) bill. The '在住但未生成账单' count therefore only detects students with zero bills ever, which under-reports the intended pending task. Consider restricting to non-cancelled bills overlapping the occupancy's billing window, e.g. `AND b.status <> 'cancelled' AND b.period_end >= o.billing_start_date`.

        LEFT JOIN bills b ON b.student_id = o.student_id
          AND b.status <> 'cancelled'
          AND b.period_end >= o.billing_start_date
        WHERE o.status = 'active'

        LEFT JOIN bills b ON b.student_id = o.student_id
        WHERE o.status = 'active'


The five count queries are independent (each is a separate `dataSource.query`), but they run sequentially inside `await` in a `for...of` loop, so total latency is the sum of all queries. For an agent-facing service that may be called with several workflows, prefer filtering eligible definitions first and executing the queries in parallel with `Promise.all`, then mapping back in the original order.

    const eligible = TASKS.filter(
      (d) => can(context, d.permission) && (!workflowKey || d.workflowKeys.includes(workflowKey)),
    );
    const tasks = await Promise.all(
      eligible.map(async (definition) => {
        if (definition.teacherRestricted && scope.type === 'teacher') {
          return {
            key: definition.key, label: definition.label, entity: definition.entity,
            permission: definition.permission, workflowKeys: definition.workflowKeys,
            count: null, restricted: true,
            detail: '当前角色数据范围不适合统计该待办，建议由教务/运营角色处理',
          } as PendingTask;
        }
        const { sql, params } = definition.sql(scope);
        const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);
        return {
          key: definition.key, label: definition.label, entity: definition.entity,
          permission: definition.permission, workflowKeys: definition.workflowKeys,
          count: Number(rows[0]?.cnt ?? 0),
        };
      }),
    );
    return tasks;

    for (const definition of TASKS) {
      if (!can(context, definition.permission)) continue;
      if (workflowKey && !definition.workflowKeys.includes(workflowKey)) continue;


`getPendingTasks` has no error handling around the DB queries: if any single count query fails (missing table/column, timeout, transient connection error), the whole method rejects and the agent receives a raw DB error with no partial results or user-friendly message. Since this service aggregates independent pending-task counts for an agent, wrap each query in a per-task try/catch and either skip the entry or return `count: null` with a `detail` explaining the failure, so one failing task does not abort the entire list.

      let count = 0;
      try {
        const { sql, params } = definition.sql(scope);
        const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);
        count = Number(rows[0]?.cnt ?? 0);
      } catch (error) {
        // Log and return count:null with a friendly detail instead of failing the whole list
      }

      const { sql, params } = definition.sql(scope);
      const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);
      const count = Number(rows[0]?.cnt ?? 0);


`utcOffset(8)` is hardcoded in both `today()` and `inDays()`, which means the '七天内到期租赁' date window silently depends on a fixed China-time assumption. If the server is deployed in another region or the business timezone ever changes, the date boundaries shift. Consider injecting the timezone offset from configuration instead of hardcoding it.

function today(): string {
  return dayjs().utcOffset(8).format('YYYY-MM-DD');
}


Multiple class property definitions are collapsed onto a single line, unlike sibling tools (e.g., get-attendance-summary.tool.ts) where each `readonly` property is on its own line. This hurts readability and makes diffs noisier. Consider splitting them one-per-line for consistency and maintainability.

  readonly name = 'get_dashboard_stats';
  readonly skillKey = 'overview';
  readonly requiredPermission = 'dashboard:view';


Type-confusion in organizationId validation: `Number(input.organizationId)` coerces values of other types, so booleans and single-element arrays slip through — e.g. `organizationId: true` becomes `1`, and `[5]` becomes `5`. Since the schema declares `type: 'integer'`, validation should require the raw value to actually be a number instead of coercing. Otherwise a malformed model payload can silently target a wrong organization.

    if (input.organizationId !== undefined) {
      if (
        typeof input.organizationId !== 'number' ||
        !Number.isInteger(input.organizationId) ||
        input.organizationId <= 0
      ) {
        return { ok: false, error: 'organizationId 必须是正整数' };
      }
      result.organizationId = input.organizationId;
    }

    if (input.organizationId !== undefined) {
      const id = Number(input.organizationId);
      if (!Number.isInteger(id) || id <= 0) {
        return { ok: false, error: 'organizationId 必须是正整数' };
      }
      result.organizationId = id;
    }


`execute` ignores the trusted `AgentToolContext` entirely and blindly trusts the model-supplied `organizationId` (falling back to the global host organization). `StudentsService.create` only asserts the target organization is active — it does not verify the current user has any access to it. Any user holding `student:create` can therefore create students in arbitrary active organizations (cross-tenant write), and there is no audit trail since `_context.userId` is unused. Consider restricting organizationId to the user's own accessible organizations (using the context) and/or recording the acting user for audit.

  async execute(input: CreateStudentInput, _context: AgentToolContext): Promise<unknown> {
    const organizationId = input.organizationId ?? (await this.resolveDefaultOrganizationId());


Inconsistency: `name` is trimmed before validation, but `studentNo` and `idNumber` are not trimmed and whitespace-only strings are accepted. This allows storing blank/whitespace values (e.g. `'   '`) that can later break lookups or duplicate checks. Trim them and reject empty results for consistency.

      if (typeof input.studentNo !== 'string' || input.studentNo.trim().length > 30) {
        return { ok: false, error: 'studentNo 必须是长度不超过 30 的字符串' };
      }
      result.studentNo = input.studentNo.trim();

      if (typeof input.studentNo !== 'string' || input.studentNo.length > 30) {
        return { ok: false, error: 'studentNo 必须是长度不超过 30 的字符串' };
      }
      result.studentNo = input.studentNo;


The `_brand` field / `CONTEXT_BRAND` symbol is effectively dead and misleading code: nothing anywhere reads `_brand` — `assertTrusted` verifies trust solely via `trustedContexts` (WeakSet), so the brand adds no real forgery resistance despite the docs claiming the context is "branded to prevent forgery". Additionally, the class-field initializer `= CONTEXT_BRAND` can never run because instances are only created via `Object.create(AgentToolContext.prototype)` (the private constructor is never invoked), so the declaration implies a default that is never applied and contradicts the factory's `defineProperty`. Recommend removing `_brand`/`CONTEXT_BRAND` and relying on the WeakSet (which cannot be spoofed), or — if keeping the brand — actually verifying it inside `assertTrusted` instead of just setting it.

private readonly _brand = CONTEXT_BRAND;


`[...user.permissions]` will throw a raw `TypeError: user.permissions is not iterable` if the runtime user record lacks `permissions` (undefined). The `AuthenticatedUser` type guarantees the field, but this factory is the trust boundary between the JWT layer and the executor, so a defensive guard that fails fast with a clear message (and never produces a broken context) would be more robust and diagnosable.


Tool registration is maintained in three separate places that must be kept in sync manually: the `imports`/`providers` array, the constructor parameter list, and these 20 `this.registry.register(...)` calls in `onModuleInit`. If a tool is added to `providers` but forgotten here (or vice versa), there is no compile-time error — the executor's registry lookup silently returns `undefined` and the tool is reported as "unknown tool" at runtime. Consider driving registration from a single source of truth, e.g. a module-level array of tool tokens used both in `providers` and iterated over in `onModuleInit` (or a custom provider factory that registers on instantiation), so adding a tool cannot be missed.

  onModuleInit(): void {
    this.registry.register(this.businessContextTool);
    this.registry.register(this.entitySchemaTool);
    this.registry.register(this.pendingTasksTool);


Limit upper bound mismatch: this tool advertises `limit` up to 100 in both the schema and validator, but `RoomsService.agentGetRoomOccupancySummary` silently clamps results to max 50 (default 20). The LLM may request e.g. `limit=100` and receive only 50 rows without knowing results were truncated, which can skew its summarization. Align the maximum to 50 (as `search-rooms.tool.ts` does) so the advertised capability matches actual behavior.

const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;

const limit = optionalPositiveInt(raw.limit, 'limit', 100); if (!limit.ok) return limit;


The tool lookup uses the raw, unsanitized `name` (model-controlled input), while `sanitizeName(name)` is only applied for audit logging. Today the registry only holds names matching the regex, so behavior is equivalent, but this creates a latent divergence: if a registered tool ever contains characters outside `[a-zA-Z0-9_]` (e.g. a hyphenated name), the audit log name and the actually-executed tool name will differ, and the sanitization step provides no defense-in-depth for the lookup itself. For consistency and hardening, perform the lookup with `safeName` so the sanitized name is the single source of truth for both the lookup and the audit/log output.

const tool = this.registry.getForExecution(safeName);

const tool = this.registry.getForExecution(name);


`sanitizeName` can return an empty string when the input is empty (e.g. `name === ''`), producing malformed audit entries such as ` [denied]` in `action`. Add an empty-result fallback so the audit trail always contains a meaningful, sanitized tool name.

    if (TOOL_NAME_RE.test(trimmed)) return trimmed;
    // Replace unsafe chars with underscore
    const replaced = trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN);
    return replaced || '_invalid';

    if (TOOL_NAME_RE.test(trimmed)) return trimmed;
    // Replace unsafe chars with underscore
    return trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN);


The response for an unknown tool (`'未知工具'`) is distinct from the response for an existing-but-unauthorized tool (`'权限不足'`). Even though both use the `denied` status, the differing `error` text lets a probing caller (e.g. an LLM or an authenticated user without the permission) enumerate which tool names are registered vs. merely forbidden. If tool-name enumeration is a concern, use the same generic message for both branches (or remove the distinct `unknownTool` message).

        undefined,
        SAFE_MESSAGES.unknownTool,
        context,


The FORBIDDEN_INPUT_KEYS check is fully redundant: the second `allowedKeys` loop below already rejects any key not in `{'studentId'}` — which includes every entry in FORBIDDEN_INPUT_KEYS (and more). The first loop can never reject a payload the second one would accept, so this is duplicate/dead validation logic. Keep only one validation path (the `allowedKeys` whitelist) and remove FORBIDDEN_INPUT_KEYS to avoid confusion about which check is authoritative.

// Unexpected keys (including sensitive ones) are rejected by the allowedKeys whitelist below.

    for (const key of Object.keys(input)) {
      if (FORBIDDEN_INPUT_KEYS.has(key)) {
        return { ok: false, error: `不允许的输入字段: ${key}` };
      }
    }


`Number(input.studentId)` silently coerces non-number types: e.g. `{"studentId": true}` becomes 1, `{"studentId": ["1"]}` or `{"studentId": "1"}` become 1. Booleans/arrays should be rejected outright rather than coerced, otherwise a malformed LLM payload can unexpectedly read a different student. Since the schema declares `type: 'integer'`, validate the raw type before conversion.

    if (typeof input.studentId !== 'number' || !Number.isInteger(input.studentId) || input.studentId <= 0) {
      return { ok: false, error: 'studentId 必须是正整数' };
    }
    const studentId = input.studentId;

    const studentId = Number(input.studentId);
    if (!Number.isInteger(studentId) || studentId <= 0) {
      return { ok: false, error: 'studentId 必须是正整数' };
    }


The `limit` contract is inconsistent: the JSON schema advertises `maximum: 50` (hardcoded again here as the second source of `50`), but when the field is omitted `optionalPositiveInt` returns `undefined` and the service silently applies its own default of 30 (`query.limit ?? 30` in attendance.service.ts). Callers who omit `limit` therefore receive fewer rows than the tool's advertised maximum, and the two hardcoded `50`s can drift apart. Suggest declaring `default: 30` (or 50) in `inputSchema` and applying the default here in `validate` (e.g. `limit: limit.value ?? 50`) so the tool's contract is explicit and matches downstream behavior.

const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;


limit 的 schema 上限和校验上限都是 50，但底层 agentSearchBills 在未传 limit 时默认只返回 20 条且没有分页/游标。LLM 不传 limit 时会把截断后的 20 条结果误认为全部匹配账单，导致后续判断/操作基于不完整数据。建议在 inputSchema 的 limit 属性中补充 description 说明默认值，或让未传时的默认行为与上限一致（例如默认也取 50）。


schema 中各属性（keyword/status/periodStart/periodEnd）都没有 description，LLM 只能根据字段名和一行 tool description 猜测参数含义；status 仅按长度(20)校验，是自由字符串，模型猜错取值时会静默返回空结果。建议在 schema 中为 status 提供可选值说明（或确认 status 为自由字符串后接受此行为），并给其他属性补充简短描述以提升工具调用的准确率。

const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;


The `status` input is only checked for string length, but it is used to filter `class.status`, whose only valid values are the `ClassStatus` enum values (`enrolling` | `active` | `ended` | `suspended`, see `entities/class.entity.ts`). The inputSchema also gives the LLM no hint about allowed values (no enum/description), so the model can easily pass invalid values (e.g. `graduated`, `ENROLLING`) and silently get empty results instead of a clear validation error. Add an `enum` to the schema and validate the value against `ClassStatus` in `validate` (import it from `../../entities/class.entity`).

const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;
    if (status.value && !Object.values(ClassStatus).includes(status.value as ClassStatus)) {
      return { ok: false, error: 'status 必须是 enrolling/active/ended/suspended 之一' };
    }


The Boolean() coercion here is redundant: the type check above (`typeof raw.includeEnded !== 'boolean'` with early return) already guarantees that when `includeEnded` is present it is a boolean, so `Boolean(raw.includeEnded)` is an identity conversion. This can be simplified to just `raw.includeEnded` (TypeScript narrows it to `boolean | undefined` after the guard).

includeEnded: raw.includeEnded === undefined ? undefined : Boolean(raw.includeEnded),


The inputSchema declares `month` as a plain string with only a description, while `validate()` enforces the YYYY-MM format and a valid month range. Since the schema is likely consumed by agent/tooling to generate or pre-validate calls, it should declare the same constraint (e.g., `pattern: '^\\d{4}-\\d{2}$'`) so schema-level validation is consistent with the runtime validator.

month: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'YYYY-MM' },

month: { type: 'string', description: 'YYYY-MM' },


Description mismatch: this tool advertises '查询宿舍及床位占用数量' (returns room and bed occupancy counts), but the underlying agentSearchRooms (room-query.service.ts) only returns room metadata (id, roomNumber, building, floor, capacity, roomType, status) — no occupancy count fields at all. Occupancy numbers are provided by the separate GetRoomOccupancySummaryTool. Since the LLM agent relies on this description to decide which tool to call and what to expect, the misleading text will produce incorrect agent behavior. Suggest correcting it, e.g. '查询宿舍基本信息及状态，不返回住户资料和占用数。'.

readonly description = '查询宿舍及床位占用数量，不返回住户资料。';


The `status` parameter is accepted as an arbitrary string (maxLength 20) with no whitelist, yet the underlying rooms.status column is a free-form varchar (default 'available'). Any value outside the known set (e.g. 'occupied', 'archived') silently yields an empty result, and because valid statuses are not enumerated in the schema, the LLM agent has no way to know which values are usable. Consider constraining `status` to the known status values in inputSchema (and/or validating against them) so invalid input is rejected with a clear error instead of returning empty results.


TOCTOU race breaks the idempotency guarantee: `findSubmission` followed by `save` is not atomic. Under concurrent duplicate submissions (double-click / parallel retries with the same artifactId+clientRequestId), both requests can pass the existence check, and the second `save` will hit the unique index `uk_ai_a2ui_submissions_artifact_client`, throwing an unhandled duplicate-key error (QueryFailedError) and returning a 500 to the caller instead of the existing submission. Recommend an atomic upsert (e.g. `insert` with `ON DUPLICATE KEY`/`ON CONFLICT DO NOTHING` depending on DB) or catching the unique-violation error and re-reading the existing row in the conflict path.

    const existing = await this.findSubmission(input.artifactId, input.clientRequestId);
    if (existing) return { created: false, submission: existing };
    const submission = await this.submissions.save(


Minor contract inconsistency: the 3rd argument of `optionalPositiveInt` is the *maximum* (30), not a default. When the LLM omits `limit`, `limit.value` is `undefined` and is passed through unchanged, so the service silently falls back to its own default of 10 results — while the tool's `inputSchema` advertises a maximum of 30 with no mention of the effective default. The agent/model has no way to know results will be capped at 10 when `limit` is omitted. Suggest setting an explicit default in `validate` (e.g., `limit: limit.value ?? 10`) or documenting the default in the schema description so the tool's contract matches actual behavior.

    const limit = optionalPositiveInt(raw.limit, 'limit', 30); if (!limit.ok) return limit;
    return {
      ok: true,
      value: {
        keyword: keyword.value,
        periodStart: periodStart.value,
        periodEnd: periodEnd.value,
        limit: limit.value ?? 10,
      },
    };

    const limit = optionalPositiveInt(raw.limit, 'limit', 30); if (!limit.ok) return limit;
    return {
      ok: true,
      value: {
        keyword: keyword.value,
        periodStart: periodStart.value,
        periodEnd: periodEnd.value,
        limit: limit.value,
      },
    };


The numeric constraints are hardcoded in two places that must stay in sync: the JSON schema exposed to the LLM (maxLength: 100/50, minimum: 1, maximum: 50) and the literal limits passed to optionalString/optionalPositiveInt in validate(). If one side is updated without the other (e.g. raising the schema maximum for `limit` without updating `optionalPositiveInt(raw.limit, 'limit', 50)`), input that is valid per the schema will be silently rejected at runtime. Suggest extracting module-level constants (e.g. `const LIMIT_MAX = 50`) and referencing them in both the schema and the validation calls.

      limit: { type: 'integer', minimum: 1, maximum: 50 },
    },
    additionalProperties: false,
  };


The manual `weekDay` range check (`> 7`) duplicates functionality already provided by `optionalPositiveInt`'s `maximum` parameter (used for `limit`). It also makes the `!== undefined` guard unnecessary, since `undefined > 7` is `false` anyway. Simplify to `optionalPositiveInt(raw.weekDay, 'weekDay', 7)`, which produces the same behavior (rejects 0/negatives/8+) with a clearer error message and less code.

    const weekDay = optionalPositiveInt(raw.weekDay, 'weekDay', 7);
    if (!weekDay.ok) return weekDay;

    const weekDay = optionalPositiveInt(raw.weekDay, 'weekDay');
    if (!weekDay.ok) return weekDay;
    if (weekDay.value !== undefined && weekDay.value > 7) {
      return { ok: false, error: 'weekDay 必须在 1-7 之间' };
    }


`Number(value)` performs very loose coercion on untrusted tool input: `true` → 1, `[5]` → 5, `"1e2"` → 100, `"0x10"` → 16 all pass as valid positive integers. Since these validators gate server-side tool execution, non-numeric types (booleans, arrays, objects, null) and non-canonical numeric strings should be rejected explicitly. Recommend a strict check, e.g. accept only `typeof value === 'number'` or a string matching `/^[1-9]\d*$/` before converting.

  const parsed =
    typeof value === 'number'
      ? value
      : typeof value === 'string' && /^[1-9]\d*$/.test(value.trim())
        ? Number(value.trim())
        : NaN;
  if (!Number.isInteger(parsed) || parsed <= 0 || (maximum !== undefined && parsed > maximum)) {

  const parsed = Number(value);
  if (!Number.isInteger(parsed) || parsed <= 0 || (maximum !== undefined && parsed > maximum)) {


The error message uses truthiness `maximum ? ...` while the validation above uses `maximum !== undefined`. If `maximum` is `0`, the constraint is applied in validation but silently omitted from the error message. Use `maximum !== undefined` for consistency.

return { ok: false, error: `${field} 必须是正整数${maximum !== undefined ? `且不超过${maximum}` : ''}` };

return { ok: false, error: `${field} 必须是正整数${maximum ? `且不超过${maximum}` : ''}` };


Length is checked on the raw value but the returned value is trimmed afterwards. This causes two inconsistencies: (1) a value like `" ab "` with maxLength 3 is rejected even though its trimmed form fits; (2) a whitespace-only string silently becomes `undefined` (treated as absent), which can mask a caller mistake. Trim first, then validate the trimmed length.

  if (typeof value !== 'string') {
    return { ok: false, error: `${field} 必须是字符串` };
  }
  const trimmed = value.trim();
  if (trimmed.length > maxLength) {
    return { ok: false, error: `${field} 必须是长度不超过${maxLength}的字符串` };
  }
  return { ok: true, value: trimmed || undefined };

  if (typeof value !== 'string' || value.length > maxLength) {
    return { ok: false, error: `${field} 必须是长度不超过${maxLength}的字符串` };
  }
  return { ok: true, value: value.trim() || undefined };


The artifact type list is defined twice: once in the union type `A2uiArtifactType` and once in the `A2UI_ARTIFACT_TYPES` Set. This duplicates the source of truth and creates a drift risk — adding a type to the union but forgetting the Set makes `buildA2uiArtifact` throw at runtime, while adding to the Set only surfaces as call-site TS errors. Better to use a single const array and derive the union type from it, e.g.:

```ts
export const A2UI_ARTIFACT_TYPES = ['form', 'review', 'chart', 'import_wizard'] as const;
export type A2uiArtifactType = (typeof A2UI_ARTIFACT_TYPES)[number];
```

and then `new Set<A2uiArtifactType>(A2UI_ARTIFACT_TYPES)` if the Set is still needed.

const A2UI_ARTIFACT_TYPES = new Set<A2uiArtifactType>([
  'form',
  'review',
  'chart',
  'import_wizard',
]);


The runtime validation only covers `type`, not `status` (or `id`/`messageId`/`conversationId`). If this function can receive runtime data (e.g., from untyped sources), an invalid `status` would silently pass through and potentially break frontend normalization. For consistency, either validate `status` with its own allow-list (e.g., `new Set<A2uiArtifactStatus>(['rendering', 'pending', 'submitted', 'expired', 'cancelled'])`) or drop the `type` check if all callers are guaranteed type-safe at compile time.

  if (!A2UI_ARTIFACT_TYPES.has(input.type)) {
    throw new Error(`未知 A2UI artifact 类型: ${String(input.type)}`);
  }


Student updates are executed strictly sequentially with `await` inside a `for` loop. These operations are independent of each other, so they can run in parallel. Given the batch supports up to 12 items, consider mapping each item to a promise (with per-item try/catch to preserve the partial-failure behavior) and awaiting them with `Promise.all` to cut the total latency to roughly the slowest single update instead of the sum.

    const results = await Promise.all(
      input.updates.map(async (item) => {
        const { id: _id, ...rest } = item;
        const dto = rest as UpdateStudentDto;
        try {
          const student = await this.studentsService.update(item.id, dto);
          return { id: item.id, name: student?.name ?? null };
        } catch (error) {
          return {
            id: item.id,
            error: error instanceof NotFoundException ? '学生不存在' : '更新失败',
          };
        }
      }),
    );
    for (const r of results) {
      if ('error' in r) failed.push(r);
      else updated.push(r);
    }

    for (const item of input.updates) {
      const { id: _id, ...rest } = item;
      const dto = rest as UpdateStudentDto;
      try {
        const student = await this.studentsService.update(item.id, dto);
        updated.push({ id: item.id, name: student?.name ?? null });
      } catch (error) {
        failed.push({
          id: item.id,
          error: error instanceof NotFoundException ? '学生不存在' : '更新失败',
        });
      }
    }


`Number(raw.id)`/`Number(raw.organizationId)` coerces non-number values: `Number(true)` is `1` and `Number(['5'])` is `5`, so a boolean `true` or a single-element array would pass the “must be a positive integer” check and update student #1. Since `validate()` is the defense-in-depth gate (the schema may not always run first), require the raw value to be a real number instead of coercing it.

      if (typeof raw.id !== 'number' || !Number.isInteger(raw.id) || raw.id <= 0) {
        return { ok: false, error: `第 ${index + 1} 条的学生 id 必须是正整数` };
      }
      const id = raw.id;

      const id = Number(raw.id);
      if (!Number.isInteger(id) || id <= 0) {


The same coercion issue applies to `organizationId`: `Number(true)` → `1`, `Number(['5'])` → `5`, so invalid payloads (booleans, arrays) pass the check. Require a real number type here as well.

        if (typeof raw.organizationId !== 'number' || !Number.isInteger(raw.organizationId) || raw.organizationId <= 0) {
          return { ok: false, error: `第 ${index + 1} 条的 organizationId 必须是正整数` };
        }
        const organizationId = raw.organizationId;

        const organizationId = Number(raw.organizationId);
        if (!Number.isInteger(organizationId) || organizationId <= 0) {


`_id` is destructured but never used — dead variable. Also note that `dto` built from `rest` is passed directly to `repo.update` in `StudentsService.update` without going through the DTO's class-validator pipeline, so any flaw in this whitelist would hit the DB directly; at minimum remove the unused binding to avoid confusion about intent.

      const rest = { ...item };
      delete rest.id;


`optionalString` accepts a trimmed empty string (`''`), and `validate()` requires only “at least one editable field”, so a payload like `{ id: 1, name: '' }` passes validation and will write an empty `name` into the database (bypassing DTO validation as noted). If empty values are meant to clear optional fields (e.g. `emergencyPhone`) that is fine, but for required fields like `name`/`studentNo`/`idNumber` empty strings should be rejected to avoid corrupting required data.

  const trimmed = value.trim();
  if (trimmed.length === 0) return { ok: false, error: '字段不能为空字符串' };
  if (trimmed.length > max) return { ok: false, error: `字段长度不能超过 ${max}` };

  const trimmed = value.trim();
  if (trimmed.length > max) return { ok: false, error: `字段长度不能超过 ${max}` };


Loose type coercion in numeric validation: `Number(input.classId)` silently accepts values that are clearly not valid per the declared schema (`type: 'integer'`), e.g. `classId: true` → 1, `classId: [5]` → 5, `classId: "5"` / `"0x10"` → 16. The same pattern applies to `organizationId` and `limit`. This makes the runtime validation inconsistent with `inputSchema` and lets malformed model output through instead of failing validation. If numeric strings are intentionally tolerated, reject booleans/arrays explicitly; otherwise require a real number to match the schema.

    if (input.classId !== undefined) {
      if (typeof input.classId !== 'number' || !Number.isInteger(input.classId) || input.classId <= 0) {
        return { ok: false, error: 'classId 必须是正整数' };
      }
      result.classId = input.classId;
    }

    if (input.classId !== undefined) {
      const id = Number(input.classId);
      if (!Number.isInteger(id) || id <= 0) {
        return { ok: false, error: 'classId 必须是正整数' };
      }
      result.classId = id;
    }


This FORBIDDEN_INPUT_KEYS loop is fully redundant: the `allowedKeys` whitelist loop right below already rejects every key not in the whitelist (including all of these forbidden keys) with the exact same error message. Maintaining two sources of truth for input key validation risks drift (e.g., a key added to `allowedKeys` but forgotten here). Either remove this block and the constant, or give it a distinct error message so it adds value as an explicit denial.

    // Reject forbidden keys
    for (const key of Object.keys(input)) {
      if (FORBIDDEN_INPUT_KEYS.has(key)) {
        return {
          ok: false,
          error: `不允许的输入字段: ${key}`,
        };
      }
    }


The description states fields are limited to 1-12, but the JSON schema has no minItems/maxItems, so providers validating against this schema will accept any number of fields. An LLM can emit dozens/hundreds of fields, inflating request payloads and downstream storage. Add minItems: 1 and maxItems: 12 to enforce the documented bound.

          fields: {
            type: 'array',
            description: '表单字段（1-12个）',
            minItems: 1,
            maxItems: 12,


The description says options are limited to 1-20, but no minItems/maxItems is declared. Add minItems: 1 and maxItems: 20 so the schema matches the documented contract and bounds the payload.

                options: {
                  type: 'array',
                  description: 'select 类型的选项（1-20个）',
                  minItems: 1,
                  maxItems: 20,


The description says columns are 2-10, but the schema has no minItems/maxItems. Add minItems: 2 and maxItems: 10 to enforce the documented bound.

          columns: {
            type: 'array',
            description: '列定义（2-10个）：第一列为类别/名称，其余列为数值序列；饼图只用前两列（名称+数值）',
            minItems: 2,
            maxItems: 10,


The description limits rows to ≤500, but no maxItems is declared. render_chart rows are the largest possible payload of the A2UI tools (and are persisted/rendered per round); without maxItems: 500 the schema accepts arbitrarily large row arrays, creating a resource/cost risk. Add maxItems: 500 to enforce the documented cap.

          rows: {
            type: 'array',
            description: '行数据（≤500行，键名须与 columns.key 对应）',
            maxItems: 500,


Redaction ordering bug: the phone-number rule runs before the ID-card rule, and the phone pattern `1[3-9]\d{9}` has no word boundaries. An 18-digit Chinese ID card containing an 11-digit mobile-like substring (e.g. `110101199003078510` contains `19900307851`) will have those 11 digits replaced first, so the subsequent `\b\d{17}[\dXx]\b` pattern can no longer match the full card and the remaining digits leak into logs — defeating the purpose of this redaction function for the most sensitive PII. Fix by redacting ID cards first and/or anchoring the phone pattern with `\b` (which also avoids mangling longer numeric fields such as account numbers).


The sensitive-key regex in this replacer is unanchored substring matching, so any key merely containing one of these words gets its whole value replaced. For example `tokenCount`, `phoneVerified`, or `mobileVersion` — which are typically benign counters/booleans, not secrets — will be rendered as `"[REDACTED]"`, corrupting log output and metrics. Consider matching exact key names (case-insensitive) or a small allowlist of known sensitive keys instead.

    if (/password|token|secret|api.?key|authorization|phone|mobile|id.?card|身份证/i.test(key)) {
      return '[REDACTED]';
    }


Header 值在判空时用了 trim()，但实际写入 mapping 并参与 `allowedHeaders.has(headerName)` 严格比较时使用的是未 trim 的 `header.slice(0, 200)`。如果确认方返回的表头带首尾空格（如 `" 姓名 "`），即使它对应的正是工作表表头也会被误判为“不在工作表表头中”而抛错。建议先 trim 再截断比较，同时落库的 headerName 也更干净。

      if (typeof header !== 'string' || !header.trim()) continue;
      const headerName = header.trim().slice(0, 200);

      if (typeof header !== 'string' || !header.trim()) continue;
      const headerName = header.slice(0, 200);


`Object.entries` 返回的 key 永远是 string，因此 `typeof field !== 'string'` 是恒为 false 的死分支。另外，`field.length > 50` 时直接静默 continue，与“未知字段直接抛错”的校验风格不一致——用户误传一个超长字段名会被静默丢弃（可能最终导致该阶段 mapping 为空），建议改为抛错或至少给出提示。

    for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
      if (!field.trim() || field.length > 50) continue;

    for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
      if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;


当某个 step 的所有字段都被跳过（空 header / 超长字段等）时，仍会把空对象 `columnMapping = {}` 写入 `mapping[typedStepKey]`。下游若以“mapping 中是否包含该 stepKey”来判断该阶段是否已确认映射，空对象会被误认为已确认。建议仅在 columnMapping 非空时才写入。

if (Object.keys(columnMapping).length > 0) mapping[typedStepKey] = columnMapping;


`attachment.mimeType.includes(...)` 直接调用，若调用方传入的 attachment.mimeType 为 undefined/null（例如来自部分解析的数据或 DB 可空列），会抛 TypeError 而不是返回 false。建议对 mimeType 做空值保护。

    attachment.mimeType?.includes('spreadsheetml') ||
    attachment.mimeType?.includes('excel') ||
    attachment.mimeType?.includes('csv') ||

    attachment.mimeType.includes('spreadsheetml') ||
    attachment.mimeType.includes('excel') ||
    attachment.mimeType.includes('csv') ||


If `execute` resolves successfully without ever calling `onReady` (headers never sent), the `finally` block only finalizes the response when `res.headersSent` is true. In the success-without-stream path nothing is written and the response is left dangling — the client hangs indefinitely with no data and no error, and the socket is only reclaimed by server timeouts. The rethrow path in `catch` only covers errors, not this case. Add a `started` flag set in `onReady` and after `await execute(...)` throw if the stream never started so the error propagates to the global exception filter (which will write a proper response since headers aren't sent yet).

    try {
      await execute(abortController.signal, emit, onReady);
      if (!res.headersSent) {
        throw new Error('AI stream finished without starting the response');
      }
    } catch (error) {
      if (!res.headersSent) throw error;

    try {
      await execute(abortController.signal, emit, onReady);
    } catch (error) {
      if (!res.headersSent) throw error;


`stream.pipe(res)` has no error handler on the read stream. If the underlying file is missing/corrupt (e.g. the file on disk was removed while the DB row still exists, or a storage failure occurs), `createReadStream` emits an async 'error' event which is not handled — the pipe stalls and the client hangs until a server-level timeout, with no error response. Attach a `stream.on('error', ...)` handler that logs and destroys/ends the response appropriately.

    stream.on('error', () => {
      if (!res.headersSent) {
        res.status(500).end();
      } else {
        res.destroy();
      }
    });
    stream.pipe(res);


For HTTP 4xx/5xx upstream errors, `error.message` is forwarded verbatim to the SSE client. Upstream/AI service messages may contain internal implementation details (host paths, model names, stack traces, database errors) that should not be exposed to end users. Return a generic user-facing message and log the real error server-side instead.

if (status >= 500) return { code: 'UPSTREAM_ERROR', message: 'AI 服务暂时不可用' };

if (status >= 500) return { code: 'UPSTREAM_ERROR', message: error.message };


TOCTOU race between the in-memory `activeConversations` check and the actual deletion. After the check there are several `await`s (attachment query, `conversations.remove`), during which a concurrently starting generation can call `acquireConversation` (which adds the id to the set synchronously) and begin writing messages to the conversation that is about to be deleted. This can result in FK errors or silently losing in-flight generation data. The same unsafe check-then-delete pattern exists in `deleteAllConversations` and `deleteMessage`. Consider re-verifying the set right before removal (and/or after the awaits) or guarding deletion with a DB-level lock/transaction.

  if (context.activeConversations.has(id)) throw new ConflictException('该会话正在生成回答');
  const attachmentIds = await context.messages


The attachment-id collection query is duplicated three times (`deleteConversation`, `deleteAllConversations`, `deleteMessage`). Extract a shared helper such as `collectAttachmentIds(context, messageIds)` so that table names, the raw-to-number mapping and join logic stay in sync if they ever change.

  const attachmentIds = await context.messages
    .createQueryBuilder('message')
    .innerJoin('message.attachments', 'attachment')
    .where('message.conversation_id IN (:...ids)', {
      ids: conversations.map((item) => item.id),
    })
    .select('attachment.id', 'id')
    .getRawMany<{ id: number }>();


`page` and `limit` are taken from user input without validation. `page = 0` (or negative) produces a negative `skip`, which TypeORM rejects or handles unpredictably (500 error), and a very large `limit` can cause an unbounded/heavy query. Clamp the values, e.g. `page = Math.max(1, page)` and `limit = Math.min(100, Math.max(1, limit))`.

    skip: (Math.max(1, page) - 1) * Math.min(100, Math.max(1, limit)),
    take: Math.min(100, Math.max(1, limit)),

    skip: (page - 1) * limit,
    take: limit,


When `rows` is missing or not an array (e.g. the model omits it or sends an object/string), this branch throws with the misleading message "图表行数不能超过 500", and an empty array `[]` is silently accepted (chart with no data). Suggest splitting the two cases: reject non-array/missing `rows` with a "必须是数组" message and reject only length overflow with the current message (same applies to the `columns` check above).

    if (!Array.isArray(rawArgs.rows) || rawArgs.rows.length > MAX_ROWS) {
      throw new BadRequestException(`图表行数不能超过 ${MAX_ROWS}`);
    }


The guard `isPlainRecord` only checks `typeof === 'object' && !Array.isArray`, so class instances, `Date`, `Map`, and objects created with a custom/null prototype also pass as "plain records". Since this input is untrusted AI-generated tool-arguments, the row/column validation here can be bypassed by non-plain objects (they are then silently treated as empty via `Object.entries`). Recommend a stricter check at the usage site, e.g. `Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null`.

if (!isPlainRecord(raw)) throw new BadRequestException(`图表第 ${index + 1} 行格式无效`);


Only the scatter column count is special-cased. Other chart types pass through with arbitrary column counts and arbitrary cell values: e.g. a `pie`/`gauge` with 3+ columns, or a `line`/`bar`/`scatter` whose value columns contain strings/booleans/null, will pass validation and can break the frontend ECharts conversion this service is meant to protect. Consider per-chart-type shape checks (e.g. pie/gauge exactly 2 columns) and enforcing that value columns are numeric for numeric chart types.

    if (rawArgs.chartType === 'scatter' && rawArgs.columns.length < 3) {
      throw new BadRequestException('散点图需要 3 列：名称、X 数值、Y 数值');
    }


`Repository.findByIds()` is deprecated in TypeORM 0.3.x and will be removed. Use the explicit query form with `In(uniqueIds)` instead so the query also filters by userId in one round trip.

const attachments = await this.attachments.find({ where: { id: In(uniqueIds) } });

const attachments = await this.attachments.findByIds(uniqueIds);


The per-attachment DB removal and file unlink are independent operations; running them sequentially in a loop serializes I/O. Since the operations don't depend on each other's results, use Promise.all to process them in parallel (still skipping attachments bound to messages).

    await Promise.all(
      attachments
        .filter((attachment) => !attachment.messages?.length)
        .map(async (attachment) => {
          await this.attachments.remove(attachment);
          await unlink(this.resolveStoragePath(attachment.storageKey)).catch(() => undefined);
        }),
    );

    for (const attachment of attachments) {
      if (attachment.messages?.length) continue;
      await this.attachments.remove(attachment);
      await unlink(this.resolveStoragePath(attachment.storageKey)).catch(() => undefined);
    }


The 10MB limit is defined here as MAX_FILE_BYTES but the enforcement at the transport layer (the controller's `FileInterceptor` `limits.fileSize`) is a separate hardcoded `10 * 1024 * 1024`. These two values can drift over time, causing the in-memory buffer check to disagree with what multer actually accepts. Export a shared constant (e.g. from a common module) and reference it in both places.

if (file.size > MAX_FILE_BYTES) throw new BadRequestException('单个附件不能超过 10MB');


The attachment URL is hardcoded here. If the route in the controller ever changes (prefix, version, module path), this serialized payload silently points clients at a 404. Derive the path from the controller route or a shared route constant instead of duplicating the string.


If this final `save` throws (e.g. transient DB failure), the file already written to disk is never cleaned up (the try/catch + unlink only guards the first save) and the DB record stays in 'processing' status, producing a permanent orphan until something explicitly removes it. Consider wrapping the post-extraction save so a failure unlinks the file (and ideally deletes the stale record), matching the cleanup done for the first save.

    } catch {
      entity.processingStatus = 'failed';
      entity.processingError = '文件内容解析失败';
    }
    entity = await this.attachments.save(entity);
    return entity;


`attachments: any[]` uses `any` without justification, which violates the project rule to avoid `any` and also diverges from the typed contract: `AiChatServiceContext` in `ai-chat.types.ts` and the concrete override in `ai-chat.service.ts` both declare `attachments: AiAttachment[]`. Using `any` here silently disables type checking for all callers/subclasses of this abstract method. Suggest typing it as `AiAttachment[]` (imported from `./entities`).

  abstract buildUserContent(
    text: string,
    attachments: AiAttachment[],
    supportsVision: boolean,
  ): Promise<string | ModelContentPart[]>;

  abstract buildUserContent(
    text: string,
    attachments: any[],
    supportsVision: boolean,
  ): Promise<string | ModelContentPart[]>;


Nested ternary expression is used to pick `modelFocusContent`. The review rules prohibit nested ternaries as they hurt readability. Consider extracting this into a small helper function with early returns, e.g. `if (formSubmit) return buildFormSubmitModelContent(formSubmit); if (reviewSubmit) return buildReviewSubmitModelContent(reviewSubmit); return focusContent;`.

    const modelFocusContent = formSubmit
      ? buildFormSubmitModelContent(formSubmit)
      : reviewSubmit
        ? buildReviewSubmitModelContent(reviewSubmit)
        : focusContent;


The tool-name allowlist/denylist is duplicated across two `filter` blocks and relies on hardcoded string literals (`create_student`, `update_students`, `render_form`, `start_import_wizard`). These tool names are business-critical; if a tool is renamed the filter silently stops working. Extract the excluded tool names into a shared constant and consolidate the two filters into one (e.g. a single helper that computes the excluded set based on `formSubmit`/`reviewSubmit`).

    if (!formSubmit && !reviewSubmit) {
      tools = tools.filter(
        (tool) =>
          tool.function.name !== 'create_student' && tool.function.name !== 'update_students',
      );
    }
    if (reviewSubmit) {
      tools = tools.filter(
        (tool) =>
          tool.function.name !== 'create_student' &&
          tool.function.name !== 'update_students' &&
          tool.function.name !== 'render_form' &&
          tool.function.name !== 'start_import_wizard',
      );
    }


Tool calls within a round are awaited sequentially even though they are independent (each call is executed based on its own parsed arguments). In a loop that can run up to `MAX_TOOL_ROUNDS` times, this serializes potentially slow tool executions and adds latency per round. Consider `Promise.all` for parallel execution (tool run records are later re-sorted by `id`, so persistence order stays deterministic); only keep sequential execution if the ordering of the per-tool SSE events (`tool.completed`/`tool.failed`) must be guaranteed.

      for (const call of toolCalls) {
        const toolResult = await executeTool(


`summarize` and `safeStructured` duplicate the same `JSON.stringify(value, this.redactingReplacer)` + try/catch pattern. Both also swallow the serialization error silently (returning a sentinel value without any log), which makes failures such as circular references or BigInt values (`TypeError: Do not know how to serialize a BigInt`) impossible to diagnose in production. Consider extracting a shared private helper (e.g. `stringifySafely`) and logging the caught error before returning the fallback.

    let json: string;
    try {
      json = JSON.stringify(value, this.redactingReplacer);
    } catch {
      return '[无法序列化]';
    }
    return this.redactText(json).slice(0, 2000);


`slice(0, 2000)` truncates the serialized string by UTF-16 code units, which can split a surrogate pair in the middle and leave an unpaired/invalid character in the summary. Since the same pattern (`safeStructured`) round-trips through `JSON.parse`, a truncated `summarize` output is also not guaranteed to be valid JSON if ever re-parsed. For a code-point-safe truncation, consider slicing with `Array.from(json)` (or `Intl.Segmenter`) instead.


`attachments` is typed as `any[]`, violating the project rule against the `any` type without justification. The entity relation is typed `AiAttachment[]` (ManyToMany), so passing `any[]` here bypasses type safety and hides invalid attachment shapes at the call sites. Import `AiAttachment` from `./entities` and use `AiAttachment[]`.


The spread `{ clientRequestId, skillKey, ...metadata }` puts `metadata` last, so any caller-supplied metadata containing `clientRequestId` or `skillKey` silently overrides the explicitly passed parameters. This can make the persisted user-message metadata diverge from the assistant message's metadata (which is stored as `{ clientRequestId, skillKey }`), causing mismatched/duplicated request identifiers. Spread `metadata` first so the explicit arguments take precedence.

metadata: { ...metadata, clientRequestId, skillKey },

metadata: { clientRequestId, skillKey, ...metadata },


Duplicate logic: `resolveFormConversationId` and `resolveReviewConversationId` are structurally identical (`await service.findOwnedPending(id, userId)` then return `.conversationId`). The same `findOwnedPending` + `.conversationId` lookup is also repeated inline in `submitForm`/`submitReview` in `ai-chat.submissions.flow.ts`. Consider extracting a small shared helper (e.g. `resolveOwnedConversationId(find: () => Promise<{ conversationId: number }>)`) so the lookup/ownership semantics stay consistent in one place.

async function resolveOwnedConversationId(
  find: () => Promise<{ conversationId: number }>,
): Promise<number> {
  const owned = await find();
  return owned.conversationId;
}

export async function resolveFormConversationId(
  context: AiChatServiceContext,
  userId: number,
  formId: string,
): Promise<number> {
  return resolveOwnedConversationId(() =>
    context.formService.findOwnedPending(formId, userId),
  );
}

export async function resolveFormConversationId(
  context: AiChatServiceContext,
  userId: number,
  formId: string,
): Promise<number> {
  const form = await context.formService.findOwnedPending(formId, userId);
  return form.conversationId;
}


`confirmReviewGroup` reports success and writes a hardcoded `status: 'success'` op-log entry even when no section was actually imported. The underlying `submitGroup` deliberately swallows per-section errors (persistent failures) and returns the review unchanged when every section in the group is already submitted or all submissions failed. The handler never verifies that at least one section transitioned to `submitted`, so the API returns 200 and logs "成功" for a failed/no-op confirm. Verify the resulting group sections before returning success (e.g., throw a ConflictException when none were newly confirmed), and derive the log status from the actual outcome.

  const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
  const groupSections = context.reviewService
    .parseSections(updated.sectionsJson)
    .filter((section) => section.type === type);
  if (groupSections.length > 0 && groupSections.every((section) => section.status !== 'submitted')) {
    throw new ConflictException(`分组「${type}」没有可确认的分表，请查看分表失败原因`);
  }


`finalizeReview` runs after the import transaction has already committed, but it performs best-effort side effects (`markReviewSubmittedOnMessage` — a message findOne+save — plus `serialize`) that can still throw. If any of these fail, the request returns an error for an import that actually succeeded, and the client's retry then hits `分表已确认导入` (409) — a confusing and inconsistent user experience. The same applies to `logImportOp` failures in both handlers. Wrap the post-commit message-metadata sync and op-log write in try/catch so a best-effort sync failure cannot misreport a committed import as failed.

async function finalizeReview(
  context: AiChatServiceContext,
  updated: AiReview,
): Promise<Record<string, unknown>> {
  try {
    await context.markReviewSubmittedOnMessage(
      updated.assistantMessageId,
      updated.conversationId,
      updated,
    );
  } catch {
    // 消息元数据同步为尽力而为，导入已提交，不影响返回结果
  }
  return context.reviewService.serialize(updated);

async function finalizeReview(
  context: AiChatServiceContext,
  updated: AiReview,
): Promise<Record<string, unknown>> {
  await context.markReviewSubmittedOnMessage(
    updated.assistantMessageId,
    updated.conversationId,
    updated,
  );
  return context.reviewService.serialize(updated);


The `sectionPermission` map is rebuilt on every invocation of `assertReviewImportPermissions`, even though it is constant. Hoist it to module scope so it is allocated once.

const SECTION_PERMISSIONS: Record<AiReviewSectionType, string> = {
  students: 'student:create',
  rooms: 'room:create',
  transfers: 'occupancy:transfer',
  checkins: 'occupancy:checkin',
};

  const sectionPermission: Record<AiReviewSectionType, string> = {
    students: 'student:create',
    rooms: 'room:create',
    transfers: 'occupancy:transfer',
    checkins: 'occupancy:checkin',
  };


The `submitted`/`expired` status checks here are duplicated inside `submitSection`/`submitGroup` (where they are re-evaluated inside a transaction). Because this pre-check is not atomic with the subsequent confirm, it can go stale under concurrent requests (e.g., a second request may pass the check and then receive the 409 from the transaction-level check). It only serves as early feedback, so keep it in sync with the service-layer checks to avoid error-message drift if statuses change.

  if (review.status === 'submitted') {
    throw new ConflictException('导入已全部确认，无需重复确认');
  }
  if (review.status === 'expired') {
    throw new ConflictException('导入预览已失效，请重新生成预览');
  }


Nested ternary expression (`type === 'students' ? 'profile' : type === 'rooms' ? 'room' : 'checkin'`) violates the project rule that nested ternaries are not allowed. Extract to a small mapping function or if/else chain for readability.

        sectionTypes.map((type) => {
          if (type === 'students') return 'profile';
          if (type === 'rooms') return 'room';
          return 'checkin';
        }),

        sectionTypes.map((type) =>
          type === 'students'
            ? 'profile'
            : type === 'rooms'
              ? 'room'
              : 'checkin',
        ),


`onReady` is invoked explicitly here and then passed to `runGenerationAndRelease`, which calls `executeGeneration` — and `executeGeneration` calls `onReady()` again at its start. The controller's `onReady` is guarded by `res.headersSent`, so the second call is a no-op today, but this double-invocation is fragile (any future change that removes the guard will fire it twice) and inconsistent with `submitForm`, which only passes `onReady` through. Keep only one path for invoking `onReady`.

    const serialized = context.reviewService.serialize(updatedReview);
    onReady();
    emit('ui.artifact', {


Non-atomic confirmation flow: `submitAll` (the actual data import) runs outside the `persistExchange` transaction, and `recordSubmission` persists a `created` record up front that is never updated to a failed/completed state on any later failure. If `submitAll` succeeds but `persistExchange` (or `markReviewSubmittedOnMessage`) throws, the review data is already imported while the conversation message is missing, and any retry with the same `clientRequestId` is rejected as a duplicate ("该导入预览已确认过"), wedging the artifact. `submitForm` has the same pattern (`markSubmitted`/`markFormSubmittedOnMessage` run after the transaction commits). Consider wrapping the artifact state change + exchange persistence in one transaction and updating the submission record's status (or deleting it) on failure so a partial failure can be retried consistently.

    const { review: updatedReview, result } = await context.reviewService.submitAll(
      review.id,
      user.id,
    );


`submitForm` and `submitReview` share a large amount of duplicated logic: fetching the owned pending artifact, `requireOwnedConversation`, computing `effectiveSkillKey`, `assertSkillAvailable`, `recordSubmission` + duplicate-check, `acquireConversation` + try/finally cleanup, `persistExchange` with the `DEFAULT_TITLE` title update, `emit('ui.artifact')`, and the final `runGenerationAndRelease`. This duplication makes it easy for fixes to one flow to miss the other (e.g., the inconsistent `onReady` handling). Consider extracting a shared helper (e.g., a generic `runSubmission` that takes the artifact-specific steps as callbacks).


This function performs DB I/O (findOne + save) without any error handling, yet it is awaited by callers after the submission transaction has already been committed (e.g., ai-chat.submissions.flow.ts:74). If the save throws (transient DB error, constraint conflict) the whole request fails for the user even though the form submission already succeeded. Also, when the assistant message is not found the function silently no-ops and the stored message metadata is never marked 'submitted', leaving it inconsistent with the emitted ui.artifact. Since this is best-effort bookkeeping, wrap the read/write in try/catch with logging (or return a boolean indicating whether the mark was applied) so failures here cannot break the completed submission flow.

export async function markFormSubmittedOnMessage(
  context: AiChatServiceContext,
  assistantMessageId: number,
  conversationId: number,
): Promise<void> {
  const assistant = await context.messages.findOne({
    where: { id: assistantMessageId, conversationId },
  });


Same robustness issue as markFormSubmittedOnMessage: this function is awaited (ai-chat.submissions.flow.ts:204, ai-chat.review-confirm.ts:27) after the review/import side effects have already been persisted, and an unhandled save error here will surface a failure response to the user despite the operation having succeeded. Additionally, in the review-provided branch the whole a2uiReview object is replaced by serialize(review), which will also drop any previously stored metadata keys on that object that are not part of the serialized shape. Consider guarding the read-modify-write with try/catch + logging.

export async function markReviewSubmittedOnMessage(
  context: AiChatServiceContext,
  assistantMessageId: number,
  conversationId: number,
  review?: AiReview,
): Promise<void> {
  const assistant = await context.messages.findOne({
    where: { id: assistantMessageId, conversationId },
  });


The truncation limit '32 * 1024' is a hard-coded magic number and String.prototype.slice operates on UTF-16 code units, so truncation can split a surrogate pair (e.g., emoji in submitted values) and can cut JSON mid-structure, producing a malformed snippet in the model prompt. Meanwhile fieldErrors is serialized with no length cap at all, so an oversized fieldErrors array can push the prompt over the context limit. Suggest extracting a named constant (e.g., MAX_JSON_SNIPPET) and applying the same truncation/length guard to fieldErrors; wrapping its JSON.stringify in try/catch would also make it consistent with the guarded submit.values serialization.

return `【表单提交：${submit.title}】${submissionId}\n提交值（JSON）：${json.slice(0, 32 * 1024)}${fieldErrors}\n用户已在表单中确认，你可以执行允许的写操作工具。`;


The transaction commits destructive changes (editing the target user message and deleting ALL later messages) before generation runs. The subsequent `context.conversations.update` and `removeOrphans` calls run outside the transaction: if either throws, or the process crashes after commit, the conversation is left with its history irrecoverably truncated plus a dangling 'pending' assistant message, and the client receives an error with no generation ever started. Consider moving the conversation title/lastMessageAt update into the same transaction (using `manager.update`), and making the orphan cleanup best-effort (catch/log) so a file-cleanup failure cannot block generation.

    await context.conversations.update(
      { id: conversationId, userId: user.id },
      {
        lastMessageAt: now,
        ...(conversation.title === oldTitleHint ? { title: context.titleFromMessage(content) } : {}),
      },
    );
    await context.attachmentService.removeOrphans(user.id, orphanAttachmentIds);


Messages whose `status !== 'completed'` (e.g., pending/cancelled assistant rows left behind by interrupted generations) are skipped in the loop, yet they still occupy one slot of the `take: MAX_HISTORY_MESSAGES + 1` fetch window. In a long conversation with several interrupted regenerations, older completed messages beyond the window are never fetched, so the effective model context silently shrinks well below MAX_HISTORY_MESSAGES. Filter completed messages in the query itself (or fetch a larger window) so skipped rows don't displace real history.

    where: { conversationId, id: LessThanOrEqual(focusUserMessageId), status: 'completed' },
    relations: { attachments: true },
    order: { createdAt: 'DESC', id: 'DESC' },
    take: MAX_HISTORY_MESSAGES + 1,


All attachment text (including the `[图片附件：…]`/`[附件：…]` markers) is merged into a single leading text part while every image is appended afterwards, destroying the original interleaving order of text and images in multi-part attachments (e.g., a scanned multi-page document where each page's OCR text should sit next to its image). The model receives all page texts before any image, which can degrade multimodal reasoning. Consider emitting text/image parts in their original sequence (one content part per attachment item) instead of collecting all text first.

return [{ type: 'text', text: combinedText }, ...contentParts];


Minor inconsistency: the persisted user content uses `dto.message.trim()`, but the auto-generated title uses the untrimmed `dto.message`. If the input has leading/trailing whitespace, the title may retain it while the stored content does not. Use the trimmed value consistently.

conversation.title === DEFAULT_TITLE ? context.titleFromMessage(dto.message.trim()) : undefined,

conversation.title === DEFAULT_TITLE ? context.titleFromMessage(dto.message) : undefined,


Organization IDs are only resolved when `raw` is an actual JS number, but the caller passes `row.organization` from AI-extracted rows (`ai-review.import-basic.ts` line 37), where values are typically strings. A numeric string such as "3" will never be matched against `org.id` and silently falls through to the name/code lookup, then to `null`/host fallback. This inconsistency makes the id resolution unreliable for the primary use case. Consider also matching numeric strings against `org.id`.

  if (typeof raw === 'number') {
    return organizations.some((org) => org.id === raw) ? raw : null;
  }
  const text = typeof raw === 'string' ? raw.trim() : '';
  if (/^\d+$/.test(text)) {
    const id = Number(text);
    return organizations.some((org) => org.id === id) ? id : null;
  }


Any empty/missing value (empty string, `undefined`, `null`, or even unexpected types like booleans/objects, since they all fall into `!text`) is silently resolved to the host organization without any warning. In the caller (`ai-review.import-basic.ts`), the warning "机构无法识别，已按本机构导入" is only emitted when `null` is returned, so data rows with a missing/garbage organization value are imported under the host org with no signal to the user. If silent defaulting is not intended for empty values, return `null` for empty input and let the caller decide (including emitting a warning).

  if (!text) {
    return organizations.find((org) => org.isHost)?.id ?? null;
  }


`AiReviewSection.type` is declared as the non-nullable union `AiReviewSectionType = 'students' | 'rooms' | 'transfers' | 'checkins'` (see ai-review.entity.ts), so the first `if` here always evaluates to true and `reviewSectionType` always returns `section.type`. The entire fallback below (the `section.key` cast, the `startsWith` loop and the `BadRequestException`) is dead code given the current types. Either this function is meant to tolerate legacy/malformed data — in which case the parameter type should be widened (e.g. `type?: AiReviewSectionType | null`) — or the unreachable fallback should be removed. Also note the literal `['students', 'rooms', 'transfers', 'checkins']` (and its four-way comparisons) is duplicated three times; extract a shared `const` (e.g. `REVIEW_SECTION_TYPES`) to keep the accepted values in sync.

  if (
    section.type === 'students' ||
    section.type === 'rooms' ||
    section.type === 'transfers' ||
    section.type === 'checkins'
  ) {
    return section.type;
  }


This is a `.types.ts` file, but most imports are runtime (value) imports even though they are only used in type positions (`DataSource`, `Repository`, `AiConfigService`, `AgentToolExecutor`, `AgentToolContextFactory`, `AiAttachmentService`, `ImportsService`, `AiChartService`, `AiExcelReaderService`, `AiFormService`, `AiReviewService`, `AiModelStreamService`, `OperationLogsService`, and the entity classes/types). Anyone importing from this types file will therefore eagerly load all those service modules, which risks circular-dependency issues and unnecessary module initialization. Only `BadRequestException` (thrown by `reviewSectionType`) needs a value import; convert the rest to `import type` (note `AgentToolContextFactory` is only used in `typeof ...` position, so `import type` is sufficient there too).

import { DataSource, Repository } from 'typeorm';
import { AiConfigService } from '../ai-config/ai-config.service';
import { AgentToolExecutor } from '../agent-tools/agent-tool.executor';
import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';


This catch block swallows the original error and reports "表单参数无效" (invalid form parameters) for ANY failure — DB errors, missing assistant message, metadata serialization failures, etc. — with no logging of the real cause. This misleads the model/user about the actual failure and makes server-side debugging nearly impossible. Capture the error (e.g., `catch (error)`) and log it; only use the "参数无效" message when the failure is genuinely an argument/validation error.

  } catch {
    await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '表单参数无效', error: '表单参数无效' }, emit);
    return JSON.stringify({ status: 'failed', error: '表单参数无效' });
  }


Same issue as executeRenderForm: the catch reports "图表参数无效" for every failure (DB error, missing assistant message, chart serialization error, etc.) and discards the actual error without logging it. Swallow-and-remap hides real bugs and misleads the model. Log the underlying error and only classify it as an argument error when appropriate.

  } catch {
    await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '图表参数无效', error: '图表参数无效' }, emit);
    return JSON.stringify({ status: 'failed', error: '图表参数无效' });
  }


Nested ternary expression — prohibited by the project's review rules. The inner `existingCharts ? [existingCharts] : []` branch is hard to read and error-prone. Rewrite with if/else branches (or extract a small helper) for clarity.

    let charts: unknown[];
    if (isUnknownArray(existingCharts)) {
      charts = [...existingCharts];
    } else if (existingCharts) {
      charts = [existingCharts];
    } else {
      charts = [];
    }

    const charts = isUnknownArray(existingCharts)
      ? [...existingCharts]
      : existingCharts
        ? [existingCharts]
        : [];


executeRenderForm and executeRenderChart share nearly the same skeleton: look up assistant message → merge/save metadata → finishToolRun(success) → emit('ui.artifact') → return success JSON, plus two identical catch blocks that remap errors to a fixed '参数无效' message. Consider extracting a shared helper (e.g., a generic save-and-emit-artifact routine) to avoid drift between the two implementations.

export async function executeRenderChart(
  context: AiChatServiceContext,
  messageId: number,
  call: ModelToolCall,
  emit: AiSseEmitter,
): Promise<string> {


The size guard is labeled `MAX_VALUES_BYTES` (64 KB) but `serialized.length` counts UTF-16 code units, not bytes. With CJK input (common in this app, cf. the Chinese messages) a value up to ~192 KB UTF-8 can be stored, and `Buffer.byteLength` is what actually matches the stated byte limit. Either use `Buffer.byteLength(serialized, 'utf8')` or rename the constant to reflect that it's a character limit.

if (Buffer.byteLength(serialized, 'utf8') > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');

if (serialized.length > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');


`markSubmitted` performs an unconditional save without re-checking `status`. `findOwnedPending` checks `status: 'pending'` earlier, but between that read and this save another request can also pass the check (double submit) or the form can be expired via `expirePreviousForms`, and this save will still overwrite the row. Use a conditional update on `{ id, status: 'pending' }` and reject the request when the affected row count is 0 to make the state transition atomic.

  async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
    const result = await this.forms.update(
      { id: form.id, status: 'pending' },
      {
        status: 'submitted',
        submittedValuesJson: JSON.stringify(values),
        submittedAt: new Date(),
      },
    );
    if (!result.affected) throw new BadRequestException('表单不存在、已提交或已失效');
    form.status = 'submitted';
    form.submittedValuesJson = JSON.stringify(values);
    form.submittedAt = new Date();
    return form;
  }

  async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
    form.status = 'submitted';
    form.submittedValuesJson = JSON.stringify(values);
    form.submittedAt = new Date();
    return this.forms.save(form);
  }


This find-then-update sequence is not atomic: a form that is submitted (or otherwise transitioned) concurrently between the `find` and the `update` will be force-marked `expired`, losing a legitimate submission. Narrow the update condition to only pending rows (e.g. add `status: 'pending'` to the where clause) so already-submitted forms cannot be overwritten, or use a single conditional UPDATE instead of read-then-write.

await this.forms.update({ id: In(ids), status: 'pending' }, { status: 'expired' });

    const expired = pending.filter((form) => form.id !== exceptFormId);
    if (expired.length === 0) return [];
    const ids = expired.map((form) => form.id);
    await this.forms.update({ id: In(ids) }, { status: 'expired' });


`DATE_RE` only checks the shape `YYYY-MM-DD`, so impossible dates such as `2023-13-40` or `2023-02-30` are accepted. Construct a `Date` and verify the round-trip (e.g. `d.toISOString().slice(0, 10) === value`) to reject non-existent calendar dates.

    if (field.type === 'date' && !DATE_RE.test(value)) {
      throw new BadRequestException(`「${field.label}」必须是 YYYY-MM-DD 格式`);
    }


`FIELD_NAME_RE` (`/^[a-zA-Z0-9_]{1,50}$/`) permits names like `__proto__`, and `result[field.name] = ...` writes into a plain object. Assigning `result['__proto__'] = value` does not create an own property (setting the prototype to a primitive is silently ignored), so that field's submitted value is silently dropped and the serialized payload is inconsistent with the schema. Use a null-prototype object (`Object.create(null)`) for `result`, or reject reserved prototype property names during schema validation.

const result: Record<string, unknown> = Object.create(null);

const result: Record<string, unknown> = {};


The `truncated` flag semantics are inverted. `slice.length < limit` means the end of the sheet was reached (i.e., nothing was cut off), while the truly truncated case is when exactly `limit` rows were returned but the sheet still has more rows (`from + limit < sheet.rows.length`). As written, the last page reports `truncated: false` and an earlier partial page can misreport, which can mislead the AI into stopping early or reading past the end.

truncated: from + limit < sheet.rows.length,


The doc comment promises a "short sample, small enough for prompts", but this implementation serializes every row of every sheet into `text`. For large workbooks this can produce enormous prompts (token/memory blow-up) and defeats the stated design of reading sheets on demand. Consider limiting the overview to a sample (e.g., the first N rows per sheet) and leaving the full dump to `extractText`.

  /** Sheet list + row counts + a short sample, small enough for prompts. */
  async overview(buffer: Buffer): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {


This empty catch block silently swallows every ExcelJS failure (corrupt file, invalid zip, unexpected library bug, etc.) and hides it behind the fallback. If the fallback also throws, the original root cause is lost, making production debugging very difficult. Log the original error before falling back, or narrow the catch to the expected WPS-style namespace failure.

    } catch {
      return this.loadWithFallback(buffer);
    }


Every `readRows` (and `overview`/`extractText`) call re-parses the entire workbook and stringifies every cell via `loadSheets`, even though only a small slice is requested. For large Excel files this is O(file size) work per page request and materializes all rows in memory, contradicting the "reads sheets on demand" design. Consider caching the parsed result per buffer (e.g., `WeakMap<Buffer, Promise<ExcelSheetRows[]>>`) or parsing only the requested sheet/range.

    const sheets = await this.loadSheets(buffer);
    const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];


Possible double-finish of the tool run: `finishToolRun` is called with `status: 'success'` first (persisting success + emitting `tool.completed`), and then `emit('ui.import_wizard')` / `emit('ui.artifact')` are called still inside the `try` block of `runImportTool`. If either SSE emit throws (e.g. connection closed), the catch block calls `finishToolRun` a second time with `status: 'failed'`, overwriting the already-saved success record and emitting a contradictory `tool.failed` after `tool.completed`; the caller also receives a `failed` JSON even though the wizard was created and persisted. Recommend emitting the UI events before the success `finishToolRun`, or moving them outside the error-handling path / marking the run as finished so the catch cannot re-finish it.

    await finishToolRun(context, run, call, startedAt, {
      status: 'success',
      summary: `已生成导入向导：${detail.steps
        .filter((step) => step.status !== 'skipped')
        .map((step) => step.label)
        .join('、')}`,
    }, emit);
    emit('ui.import_wizard', { messageId, wizard });


`parsedRecord.stages` is cast directly to `ImportStageRequest[]` without verifying each element is a non-null object. If the model returns stages containing e.g. `null`, a number or a string, `stage.stepKey` (and `expandStageSheets(stage)`) will throw a generic `TypeError` that is only caught by the outer catch in `runImportTool`, so the user gets an opaque failure instead of the intended validation message. Add an element-level guard before accessing `stage.stepKey`/`expandStageSheets` (e.g. `typeof stage === 'object' && stage !== null`).

    for (const stage of stages) {
      if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {


The catch block swallows the original error without logging it anywhere, which makes server-side debugging of failed import-wizard runs difficult. Additionally, `finishToolRun` inside this catch can itself throw (e.g. DB save failure or SSE emit failure); in that case the error propagates unhandled and the intended failed JSON response is never returned. Consider logging the original error and wrapping the failure finalization so a response is still produced.

  } catch (error) {
    const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`;
    await finishToolRun(context, run, call, startedAt, {
      status: 'failed',
      summary,
      error: summary,
    }, emit);
    return JSON.stringify({ status: 'failed', error: run.resultSummary });


The `AiReviewSectionResult` union declares the same member twice (`{ created: ... }` in both branches) — the second branch is a copy-paste typo and should be `{ completed: ... }`. As written, the union collapses to a single shape, so the `completed` variant is no longer part of the type: `importTransfers`/`importCheckins`/`parseStoredSectionResult` return `{ completed, ... }` objects that are not assignable to this type, and callers in `mergeStepResult`/`sectionResultMessage` are forced to use unsafe `as` casts. Fix the second member.

export type AiReviewSectionResult =
  | { created: number; skipped: number; issues: string[] }
  | { completed: number; skipped: number; issues: string[] };

export type AiReviewSectionResult =
  | { created: number; skipped: number; issues: string[] }
  | { created: number; skipped: number; issues: string[] };


`DATE_RE` only checks the YYYY-MM-DD *format*, not calendar validity — strings like "2026-02-31" or "2026-13-45" pass and are returned as valid dates. Downstream this value is stored directly into date columns (`checkOutDate`/`checkInDate`) and compared as a string, so an impossible date can be persisted or silently rolled over (e.g. by dayjs) into a different day, corrupting the data. Consider validating the parsed date components (e.g. reconstruct a UTC Date and check year/month/day round-trip) before returning.

export function toDateString(value: unknown): string | null {
  if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim();
  return null;
}


The final fallback branch of `sectionResultMessage` silently assumes any key that is not students/rooms/transfers is `checkins` (the default `return` is the check-in message). If a new section type is added to `SECTION_TYPES`/`SECTION_ORDER`, it will be mislabeled with the check-in message instead of failing loudly. Make the `checkins` branch explicit and throw (or otherwise handle) unknown keys for clarity and future safety.

  if (key === 'transfers') {
    return `成功换宿 ${(result as { completed: number }).completed} 条，跳过 ${result.skipped} 条`;
  }
  return `成功入住 ${(result as { completed: number }).completed} 条，跳过 ${result.skipped} 条`;


`AiReviewStepSubmitResult` and `AiReviewSubmitResult` are pure interfaces (defined as `interface`/`type` in ai-review.shared.ts) and are used only in type positions. They are imported as value imports here, which is inconsistent with the `import type` style used everywhere else in this file. Under TS configs with `verbatimModuleSyntax`/`isolatedModules` this fails to compile, and a bundler would try to resolve a non-existent runtime export. Use a type-only import.

import type {
  AiReviewStepSubmitResult,
  AiReviewSubmitResult,
} from './ai-review.shared';

import {
  AiReviewStepSubmitResult,
  AiReviewSubmitResult,
} from './ai-review.shared';


`findOwnedPending` and the `findOwned` method below are nearly identical (same lookup, different `status` filter and error message), and `findOwned` also duplicates the exported `findOwned` helper in ai-review.submit.ts. Consider extracting a single private helper, e.g. `findOwned(reviewId, userId, status?)`, so the lookup/not-found logic is defined once and the two public methods only add their specific filter/message.

  async findOwnedPending(reviewId: string, userId: number): Promise<AiReview> {
    const review = await this.reviews.findOne({
      where: { id: reviewId, userId, status: 'pending' },
    });
    if (!review) throw new NotFoundException('导入预览不存在、已确认或已失效');
    return review;
  }


The `reviewSubmitted` parameter is accepted and forwarded all the way from the generation flow (`ai-chat.generation.ts` passes `Boolean(reviewSubmit)`) but is never referenced inside `executeTool`. The write-tool gate only checks `allowWriteTools`, so the review-submission state has no effect on tool execution. Either remove the dead parameter or use it in the guard (e.g., deny write tools unless the review has been submitted), otherwise the intended "review must be submitted first" policy is silently not enforced.


Write-tool names are hardcoded here and again in `denyWriteTool` ('create_student'). This is an authorization-relevant guard: any write tool registered later (e.g. delete_student, create_room) would bypass this deny-before-form-confirmation check because it is not in this literal list, and the two copies can drift. Derive the write-tool set from the tool registry/metadata (e.g., a `write` capability flag or `requiredPermission` prefix) instead of hardcoding tool names.

if ((call.name === 'create_student' || call.name === 'update_students') && !allowWriteTools) {


There is no try/catch around `context.toolExecutor.execute` or the subsequent `summarize`/`safeStructured`/`toolRuns.save` calls. `tool.started` has already been emitted and the DB row is saved with status 'running'; if any of these steps throws, the run is left permanently in 'running' state, no `tool.failed` event is emitted, and the error propagates without a tool result payload — breaking the SSE/message flow contract. Wrap the execution + persistence in try/catch and, on failure, mark the run 'failed', save it, emit `tool.failed`, and return a safe error JSON.

const result = await context.toolExecutor.execute(call.name, parsedArgs, agentContext, allowedSkillKey);


This finishing block (status/resultSummary/durationMs update, `toolRuns.save`, and the `tool.completed`/`tool.failed` emit) duplicates the logic already provided by `finishToolRun`, which is used by the tool-action modules. Reuse `finishToolRun` (extending it to also persist `resultData`/`skillKey` if needed) to avoid the two implementations drifting apart (e.g., one day only one of them is updated to include new fields/events).

run.resultSummary = context.summarize(result.result ?? result.error ?? null);


`context.safeStructured(parsedArgs)` returns `unknown`; the cast to `Record<string, unknown> | null` is unsound when the model supplies array-shaped tool arguments (the entity's `resultData` column explicitly allows `unknown[]`, so arrays are expected elsewhere). Casting only hides the type mismatch. Preserve the real shape (e.g., widen `AiToolRun.argumentsData` to `Record<string, unknown> | unknown[] | null` and let the value flow without a cast) so type errors surface instead of being suppressed.

(context.safeStructured(parsedArgs) as Record<string, unknown> | null),


The 32KB truncation check measures `modelPayload.length` (UTF-16 code units), not byte size. With Chinese/other multibyte content a payload can be up to ~3x the byte budget (96KB) while still passing this check, which may exceed the model context limit it is meant to protect. Use `Buffer.byteLength(modelPayload, 'utf8') <= 32 * 1024` (or an explicit byte-based limit) for the comparison.

if (modelPayload.length <= 32 * 1024) return modelPayload;


Inconsistent error handling: malformed JSON is silently treated as "no sections" (`return []`), while structurally invalid items throw BadRequestException. If `sectionsJson` in the DB is corrupted/truncated, callers such as `submitAll` (ai-review.submit.ts) will get an empty array and proceed as if the review is fully submitted, silently masking data loss. Recommend throwing a BadRequestException here as well (or applying a single consistent error policy) so corrupt data is surfaced instead of swallowed.

  } catch {
    throw new BadRequestException('导入预览数据解析失败');
  }
  if (!Array.isArray(parsed)) {
    throw new BadRequestException('导入预览数据格式无效');
  }

  } catch {
    return [];
  }
  if (!Array.isArray(parsed)) return [];


Only `typeof item.key === 'string'` is validated here, but the shared module defines `SECTION_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/` and `submitSection` (ai-review.submit.ts) rejects keys that fail this regex. A key that starts with a valid type prefix (e.g., `students_`) but is longer than 50 chars or contains invalid characters passes `parseSections` and is persisted, yet can never be submitted via the API afterwards. Validate the key against `SECTION_KEY_RE` at parse time for consistency.

    if (
      !isPlainRecord(item) ||
      typeof item.key !== 'string' ||
      !SECTION_KEY_RE.test(item.key)
    ) {
      throw new BadRequestException('导入预览分表格式无效');
    }

    if (!isPlainRecord(item) || typeof item.key !== 'string') {
      throw new BadRequestException('导入预览分表格式无效');
    }


`columns`/`rows`/`issues` are only checked with `Array.isArray`; their element shapes are not validated. The persisted JSON is AI-generated/user-supplied, so e.g. rows may contain nested objects/arrays that violate `AiReviewRow` (scalar values only), columns may lack `key`/`title` strings, and issues may contain non-strings. This unvalidated data flows directly into section import and frontend rendering, risking runtime errors. Consider validating element types (or at least normalizing them) here.

      columns: Array.isArray(section.columns) ? section.columns : [],
      rows: Array.isArray(section.rows) ? section.rows : [],
      issues: Array.isArray(section.issues) ? section.issues : [],


After `Array.isArray(parsed)`, `parsed` is narrowed to `any[]`, so `item` here is implicitly `any`. Per the project's type rules (`any` should be avoided or annotated with a reason), type the array explicitly, e.g. cast to `unknown[]` before `map` and narrow inside the callback.

return (parsed as unknown[]).map((item) => {