feat(rules): rule_group/rule_atom (0033), /rules endpoints, evaluation via referenced rule groups

- migration 0033: rule_group (layers, hard excludes, scope, source, revision,
  fingerprint, default_order) and rule_atom; acquisition_slot.rule_group_ids;
  preference tags migrated into rule groups (same id/fingerprint), suppressions
  linked; seeded default groups for acquisition / upgrade / search
- contracts: RuleGroup/RuleAtom/RuleExpr/RuleDefaults, parse/serialize/preview
  schemas; Api.rules, Api.ruleAtoms, Api.rulePolicies, Api.ruleSuppressions
- server: GET/POST/PATCH/DELETE /rules, /rules/{id}/revisions, /rules/{id}/preview,
  /rules/parse, /rules/serialize, /rules/keywords, /rules/defaults, /rules/atoms;
  disable/delete writes suppression, agent re-add -> 409
- /preference-tags now aliases rule groups; /policy-revisions and
  /preference-suppressions kept as aliases of /rules/policies, /rules/suppressions
- evaluateAcquisition resolves slot -> subscription -> default chain (+ upgrade
  defaults) and records pri / rule_group@revision / per-layer trace

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:08:00 +08:00
1 parent a0946bf480
commit 0105e5c6ac
11 files changed
+2296 -340

No files matched your search

@@ -0,0 +1,90 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// W9-D 规则组:收录策略的过滤条件 + 偏好标签合一(docs/design/subscriptions.md §6)。
// rule_group = 结构化层(每层一个 AND/OR/NOT 表达式,第 k 层 pri = 100 - k)+ 硬排除;规则串只是它的文本表示。
// rule_atom = 自定义条件(对齐 MP 自定义规则 7 字段,不允许覆盖内置关键字)。
// 偏好标签原样迁成规则组(同 id、同指纹,legacy_kind/legacy_rule 保留旧语义),旧端点改为别名;抑制记录挂到规则组。
// 版本目标 rule_group_ids 有序引用规则组;default_order 非空 = 该类(订阅/洗版/搜索)默认链的一员。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"rule_group",
`
name text not null check (length(name) between 1 and 80),
description text,
kind text not null default 'acquisition' ${oneOf("kind", ["acquisition", "upgrade", "search"])},
scope jsonb not null default '{}'::jsonb,
layers jsonb not null default '[]'::jsonb,
hard_excludes jsonb not null default '[]'::jsonb,
ladder_required boolean not null default true,
rule_string text,
source text not null default 'manual' ${oneOf("source", ["manual", "chat", "agent"])},
enabled boolean not null default true,
revision integer not null default 1 check (revision >= 1),
fingerprint text not null,
evidence jsonb not null default '{}'::jsonb,
default_order integer,
auto_fill boolean,
legacy_kind text check (legacy_kind is null or legacy_kind in ('soft', 'hard', 'version', 'target')),
legacy_rule jsonb,
suppressed_at timestamptz,
deleted_at timestamptz`,
)}
create index rule_group_live_idx on rule_group (kind, default_order) where deleted_at is null;
create index rule_group_fingerprint_idx on rule_group (fingerprint) where deleted_at is null;
${entityTable(
"rule_atom",
`
key text not null unique check (key ~ '^([A-Z][A-Z0-9]*|[0-9]+[A-Z][A-Z0-9]*)$'),
name text,
description text,
include jsonb not null default '[]'::jsonb,
exclude jsonb not null default '[]'::jsonb,
match_fields jsonb not null default '[]'::jsonb,
unit_size_mb jsonb,
min_seeders integer check (min_seeders is null or min_seeders >= 0),
publish_delay_minutes jsonb,
free boolean not null default false`,
)}
alter table acquisition_slot add column rule_group_ids jsonb not null default '[]'::jsonb check (jsonb_typeof(rule_group_ids) = 'array');
alter table preference_suppression add column rule_group_id uuid references rule_group (id) on delete set null;
create index preference_suppression_rule_group_idx on preference_suppression (rule_group_id) where rule_group_id is not null;
insert into rule_group (id, name, description, kind, scope, layers, hard_excludes, ladder_required, source, enabled,
revision, fingerprint, evidence, legacy_kind, legacy_rule, suppressed_at, deleted_at,
created_at, updated_at)
select id, label, description, 'acquisition', scope, '[]'::jsonb, '[]'::jsonb, kind <> 'soft',
case origin when 'manual' then 'manual' when 'conversation' then 'chat' else 'agent' end,
status = 'active', revision, fingerprint, evidence, kind, rule,
case when status <> 'active' then updated_at end,
case when status = 'deleted' then updated_at end,
created_at, updated_at
from preference_tag;
update preference_suppression set rule_group_id = preference_tag_id where preference_tag_id is not null;
insert into rule_group (name, description, kind, source, fingerprint, default_order) values
('订阅默认', '订阅没有指定规则组时使用', 'acquisition', 'manual', 'seed:acquisition', 0),
('洗版默认', '版本目标开启升级时额外叠加', 'upgrade', 'manual', 'seed:upgrade', 0),
('搜索默认', '手动搜索与 Agent 搜索资源时使用', 'search', 'manual', 'seed:search', 0);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table preference_suppression drop column if exists rule_group_id;
alter table acquisition_slot drop column if exists rule_group_ids;
${dropTables(["rule_atom", "rule_group"])}
`,
);
}
+53
View File
@@ -206,6 +206,8 @@ export type ResolutionAttemptOutcome =
| "manual"
| "skipped_locked";
export type ResolutionAttemptActorType = "user" | "agent" | "system" | "api_key";
export type RuleGroupKind = "acquisition" | "upgrade" | "search";
export type RuleGroupSource = "manual" | "chat" | "agent";
export type ScheduledJobBoardGroup = "collect" | "download" | "organize" | "maintain" | "agent";
export type ScheduledJobKind = "task" | "agent_check";
export type ScheduledJobLastStatus = "ok" | "failed" | "skipped" | "running";
@@ -289,6 +291,7 @@ export interface AcquisitionSlotTable {
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
reserved_plan_id: string | null;
rule_group_ids: Generated<Json>;
}
export interface AdminSessionTable {
@@ -1056,6 +1059,7 @@ export interface PreferenceSuppressionTable {
lifted_by: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
rule_group_id: string | null;
}
export interface PreferenceTagTable {
@@ -1108,6 +1112,47 @@ export interface ResolutionAttemptTable {
updated_at: GeneratedTimestamptz;
}
export interface RuleAtomTable {
id: Generated<string>;
key: string;
name: string | null;
description: string | null;
include: Generated<Json>;
exclude: Generated<Json>;
match_fields: Generated<Json>;
unit_size_mb: Json | null;
min_seeders: number | null;
publish_delay_minutes: Json | null;
free: Generated<boolean>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface RuleGroupTable {
id: Generated<string>;
name: string;
description: string | null;
kind: Generated<RuleGroupKind>;
scope: Generated<Json>;
layers: Generated<Json>;
hard_excludes: Generated<Json>;
ladder_required: Generated<boolean>;
rule_string: string | null;
source: Generated<RuleGroupSource>;
enabled: Generated<boolean>;
revision: Generated<number>;
fingerprint: string;
evidence: Generated<Json>;
default_order: number | null;
auto_fill: boolean | null;
legacy_kind: string | null;
legacy_rule: Json | null;
suppressed_at: Timestamptz | null;
deleted_at: Timestamptz | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface ScheduledJobTable {
id: string;
name: string;
@@ -1515,6 +1560,8 @@ export interface DB {
preference_tag: PreferenceTagTable;
reconcile_run: ReconcileRunTable;
resolution_attempt: ResolutionAttemptTable;
rule_atom: RuleAtomTable;
rule_group: RuleGroupTable;
scheduled_job: ScheduledJobTable;
scheduled_job_run: ScheduledJobRunTable;
secret: SecretTable;
@@ -1682,6 +1729,12 @@ export type ReconcileRunUpdate = Updateable<ReconcileRunTable>;
export type ResolutionAttemptRow = Selectable<ResolutionAttemptTable>;
export type NewResolutionAttempt = Insertable<ResolutionAttemptTable>;
export type ResolutionAttemptUpdate = Updateable<ResolutionAttemptTable>;
export type RuleAtomRow = Selectable<RuleAtomTable>;
export type NewRuleAtom = Insertable<RuleAtomTable>;
export type RuleAtomUpdate = Updateable<RuleAtomTable>;
export type RuleGroupRow = Selectable<RuleGroupTable>;
export type NewRuleGroup = Insertable<RuleGroupTable>;
export type RuleGroupUpdate = Updateable<RuleGroupTable>;
export type ScheduledJobRow = Selectable<ScheduledJobTable>;
export type NewScheduledJob = Insertable<ScheduledJobTable>;
export type ScheduledJobUpdate = Updateable<ScheduledJobTable>;
+3
View File
@@ -99,6 +99,9 @@ const EXPECTED_TABLES = [
// W7-C 定时任务注册表(0028)
"scheduled_job",
"scheduled_job_run",
// W9-D 规则组(0033)
"rule_group",
"rule_atom",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */