diff --git a/alembic/versions/0118_rule_moment_judgments.py b/alembic/versions/0118_rule_moment_judgments.py new file mode 100644 index 00000000..3884ea0e --- /dev/null +++ b/alembic/versions/0118_rule_moment_judgments.py @@ -0,0 +1,61 @@ +"""rule_moment_judgments — whether a rule belongs on a moment, and who said so +(milestone 458 step 7, #4925) + +Revision ID: 0118 +Revises: 0117 +Create Date: 2026-10-05 + +One row per (rule, moment) with a state: `suggested` while a proposal waits +for a judgment, `confirmed` or `rejected` once one is made. The mount itself +stays in rule_moments; this records the proposals that are not mounts yet and +the rejections that must stop them being proposed again. Moment '' is "no +moment fits this rule". CASCADE on the rule — a judgment about a rule that no +longer exists says nothing. No backfill: the mounts made before this table +were judgments too, but recording them now would invent who made them. +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision = "0118" +down_revision = "0117" +branch_labels = None +depends_on = None + +# One place each, so the CHECKs and the model's JUDGMENT_STATES / SOURCES +# cannot drift (rule 36: a new value later means DROP + ADD CONSTRAINT in the +# same migration). +_STATES = ("suggested", "confirmed", "rejected") +_SOURCES = ("pass", "signal", "edit") + + +def upgrade() -> None: + op.create_table( + "rule_moment_judgments", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("moment", sa.Text(), nullable=False), + sa.Column("state", sa.Text(), nullable=False, server_default="suggested"), + sa.Column("source", sa.Text(), nullable=False, server_default="pass"), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("evidence", postgresql.JSONB(), nullable=True), + sa.Column("judged_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.UniqueConstraint("rule_id", "moment", name="uq_rule_moment_judgments_pair"), + ) + op.create_check_constraint( + "ck_rule_moment_judgments_state", "rule_moment_judgments", + "state IN (" + ", ".join(f"'{s}'" for s in _STATES) + ")", + ) + op.create_check_constraint( + "ck_rule_moment_judgments_source", "rule_moment_judgments", + "source IN (" + ", ".join(f"'{s}'" for s in _SOURCES) + ")", + ) + op.create_index("ix_rule_moment_judgments_rule_id", "rule_moment_judgments", ["rule_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_moment_judgments_rule_id", table_name="rule_moment_judgments") + op.drop_constraint("ck_rule_moment_judgments_source", "rule_moment_judgments", type_="check") + op.drop_constraint("ck_rule_moment_judgments_state", "rule_moment_judgments", type_="check") + op.drop_table("rule_moment_judgments") diff --git a/frontend/src/api/moments.ts b/frontend/src/api/moments.ts index 9a8f8581..70dd0b07 100644 --- a/frontend/src/api/moments.ts +++ b/frontend/src/api/moments.ts @@ -99,3 +99,59 @@ export function unmapAction(change: MappingChange): Promise { }); return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`); } + +/** + * Proposals that a rule belongs on a moment (milestone 458 step 7) — from a + * pass that read the rule, or from the rule being opened just after the + * moment fired. `GET /api/retrieval/moments/proposals`, the payload the + * `rule_moment_proposals` MCP tool returns. + */ +export type ProposalSource = "pass" | "signal" | "edit"; + +export interface MomentProposal { + moment: string; + source: ProposalSource; + why: string; + evidence: { situations: number; projects: number; co_surfaced: number }; + created_at: string | null; +} + +export interface RuleProposals { + id: number; + title: string; + kind: string; + statement: string; + when_to_apply: string; + home: "global" | "project"; + /** What the rule is mounted on now. */ + mounted: string[]; + proposals: MomentProposal[]; +} + +export interface ProposalsPayload { + rules: RuleProposals[]; + total: number; +} + +export type Verdict = "confirm" | "reject"; + +export interface MomentJudgment { + rule_id: number; + moment: string; + verdict: Verdict; + note?: string; +} + +export interface JudgeResult { + judged: { rule_id: number; moment: string; state: string }[]; + refused: { rule_id: number; moment?: string; error: string }[]; +} + +export function getProposals(ruleId?: number): Promise { + return apiGet(`/api/retrieval/moments/proposals${ruleId ? `?rule_id=${ruleId}` : ""}`); +} + +/** A confirm MOUNTS the rule on the moment; a reject stops it being proposed again. */ +export function judgeProposals(judgments: MomentJudgment[]): Promise { + return apiPost("/api/retrieval/moments/proposals/judge", { judgments }); +} diff --git a/frontend/src/components/MomentProposals.vue b/frontend/src/components/MomentProposals.vue new file mode 100644 index 00000000..2250bf29 --- /dev/null +++ b/frontend/src/components/MomentProposals.vue @@ -0,0 +1,138 @@ + + + + + + +