Rules become findable: canon catalog, triggers, tiers, edges, retrieval, surfacing (milestone 307, steps 1–5) (#131)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 15s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 15s
Steps 1-5 of milestone 307. Design in note 3026; the step-6 true-up proposal is note 3061 and needs this deployed first. Behaviour-neutral by construction: tier defaults to always_on, so every rule this instance already has keeps binding exactly as it did. That guarantee is the first case in tests/test_integration_rule_surfacing.py, against real Postgres. Migrations 0087-0089 are additive. First boot backfills rule embeddings in the background. Plugin manifest at 0.1.47.
This commit was merged in pull request #131.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""canonical_systems — the global area vocabulary, promoted from a constant
|
||||
to a table (milestone 307 step 1, decision note 3026)
|
||||
|
||||
Revision ID: 0087
|
||||
Revises: 0086
|
||||
Create Date: 2026-08-26
|
||||
|
||||
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
|
||||
services/systems.py that milestone 297 seeds into a project at inception. A
|
||||
constant cannot be referenced: a rule that applies across projects has nothing
|
||||
to point at, because `systems.project_id` is NOT NULL and a family rule cannot
|
||||
be chained to one project's row. This makes the vocabulary a table so it can be
|
||||
a foreign key, and adds the nullable `systems.canonical_id` that maps a
|
||||
project's local System onto it.
|
||||
|
||||
Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits
|
||||
the vocabulary rather than re-earning it. `record_systems` is untouched — it
|
||||
joins note_id/system_id and never sees this table, so no association data
|
||||
moves, and no System's own `name` is rewritten.
|
||||
|
||||
The seed rows are written here verbatim rather than imported from the service:
|
||||
a migration is a historical record and must keep running unchanged after the
|
||||
service's list moves on.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0087"
|
||||
down_revision = "0086"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# (name, slug, description) — the milestone-297 vocabulary, with the slug the
|
||||
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
|
||||
# collapsed to "-"). Charters stay generic on purpose: a project refines its
|
||||
# own System's description, never this one. Nothing here names an app, a repo,
|
||||
# a vendor or a house convention — the catalog ships to every install (rule 115).
|
||||
_SEED = (
|
||||
("CI & Release", "ci-and-release",
|
||||
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
|
||||
("Auth & Access", "auth-and-access",
|
||||
"Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
|
||||
("Data Model & Storage", "data-model-and-storage",
|
||||
"What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
|
||||
("API Surface", "api-surface",
|
||||
"The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
|
||||
("UI & Design", "ui-and-design",
|
||||
"What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
|
||||
("Import & Export", "import-and-export",
|
||||
"Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
|
||||
("Background Jobs", "background-jobs",
|
||||
"Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
|
||||
("Observability", "observability",
|
||||
"How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
canonical_systems = op.create_table(
|
||||
"canonical_systems",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("slug", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
# Unique among LIVE rows only, so a soft-deleted entry doesn't block
|
||||
# recreating or restoring the same area (the rules/topics convention).
|
||||
op.create_index(
|
||||
"uq_canonical_systems_slug", "canonical_systems", ["slug"],
|
||||
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.bulk_insert(
|
||||
canonical_systems,
|
||||
[
|
||||
{"name": name, "slug": slug, "description": description, "order_index": index}
|
||||
for index, (name, slug, description) in enumerate(_SEED)
|
||||
],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"systems",
|
||||
sa.Column("canonical_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
# SET NULL, not CASCADE: retiring a catalog entry must never delete a
|
||||
# project's System along with it.
|
||||
op.create_foreign_key(
|
||||
"fk_systems_canonical_id", "systems", "canonical_systems",
|
||||
["canonical_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"])
|
||||
|
||||
# Existing Systems are left UNMAPPED on purpose. An exact-slug match would
|
||||
# be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment
|
||||
# call — those go through the propose/confirm path so a human approves each
|
||||
# one, rather than being decided by a migration nobody reviews.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_systems_canonical_id", table_name="systems")
|
||||
op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey")
|
||||
op.drop_column("systems", "canonical_id")
|
||||
op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems")
|
||||
op.drop_table("canonical_systems")
|
||||
@@ -0,0 +1,105 @@
|
||||
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
|
||||
step 3, decision note 3026)
|
||||
|
||||
Revision ID: 0088
|
||||
Revises: 0087
|
||||
Create Date: 2026-08-26
|
||||
|
||||
A rule could not say WHEN it applies, WHICH area it is about, or WHAT other
|
||||
rule it belongs with. All three were being written as prose instead — a
|
||||
project's System description restating rule text, a rule's `why` naming the
|
||||
note that caused it, and two halves of one shape merged into a single row
|
||||
because either could surface without the other.
|
||||
|
||||
Four additions, each replacing something that was already being said in words:
|
||||
|
||||
- `when_to_apply` — the trigger. Nullable HERE and required at the service
|
||||
layer, because existing rules have none and a migration cannot invent one.
|
||||
- `tier` — `always_on` (preloaded, as everything is today) or `conditional`
|
||||
(reachable, surfaced when its trigger fires). Defaults to `always_on`, so
|
||||
this migration changes NOTHING about which rules bind: an install upgrades
|
||||
and every rule keeps arriving exactly as it did.
|
||||
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
|
||||
already have.
|
||||
- `rule_systems` / `rule_relations` — the canon tag and the typed edges.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0088"
|
||||
down_revision = "0087"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
|
||||
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
|
||||
# there is no prior constraint here, so the pair is created together).
|
||||
_TIERS = ("always_on", "conditional")
|
||||
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
|
||||
|
||||
def _in_list(column: str, values: tuple[str, ...]) -> str:
|
||||
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
|
||||
)
|
||||
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
|
||||
|
||||
# SET NULL, not CASCADE: the record that prompted a rule can be trashed
|
||||
# without taking the rule with it — provenance is a claim about history,
|
||||
# and losing the source does not repeal the rule.
|
||||
op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_rules_arose_from_id", "rules", "notes",
|
||||
["arose_from_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# Which global AREA a rule is about. Points at the canonical catalog, never
|
||||
# at a project's `systems` row — a rule that spans projects cannot be
|
||||
# chained to one project's vocabulary (0087).
|
||||
op.create_table(
|
||||
"rule_systems",
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"])
|
||||
|
||||
# Typed edges between rules. Each kind exists because its absence forced a
|
||||
# workaround: co_surfaces (merging two rules into one row), overrides (a
|
||||
# stricter project rule written as a duplicate), elaborates (a local
|
||||
# addendum sitting beside its parent with nothing to say it is one).
|
||||
op.create_table(
|
||||
"rule_relations",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"),
|
||||
# A rule cannot relate to itself, and one pair carries a given kind
|
||||
# once — a second row would surface the same rule twice.
|
||||
sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"),
|
||||
sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
|
||||
)
|
||||
op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"])
|
||||
op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_relations_to", table_name="rule_relations")
|
||||
op.drop_index("ix_rule_relations_from", table_name="rule_relations")
|
||||
op.drop_table("rule_relations")
|
||||
op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems")
|
||||
op.drop_table("rule_systems")
|
||||
op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey")
|
||||
op.drop_column("rules", "arose_from_id")
|
||||
op.drop_constraint("ck_rules_tier", "rules", type_="check")
|
||||
op.drop_column("rules", "tier")
|
||||
op.drop_column("rules", "when_to_apply")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
|
||||
decision note 3026)
|
||||
|
||||
Revision ID: 0089
|
||||
Revises: 0088
|
||||
Create Date: 2026-08-26
|
||||
|
||||
Rules were the only major record type with no vector, so `search` could never
|
||||
return one and a rule could only ever arrive by being preloaded. That single
|
||||
fact is what made every rule compete for the same always-on budget.
|
||||
|
||||
A sibling table rather than a generalisation of note_embeddings: the row could
|
||||
have been made polymorphic, but the SEARCH could not — semantic_search_notes is
|
||||
Note-specific scoping end to end, and a rule shares none of it. See the model
|
||||
docstring for the full reasoning.
|
||||
|
||||
The vectors are DERIVED data. Nothing is backfilled here: the startup backfill
|
||||
regenerates them, which is also how a chunker-version bump is handled.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0089"
|
||||
down_revision = "0088"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Matches note_embeddings — bge-small-en-v1.5, 384-dim unit-normalized.
|
||||
_EMBEDDING_DIM = 384
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_embeddings",
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("chunk_index", sa.Integer(), primary_key=True),
|
||||
sa.Column("chunk_text", sa.Text(), nullable=False),
|
||||
sa.Column("chunker_version", sa.Integer(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
# The vector column is added by raw DDL for the same reason 0067 did it:
|
||||
# the type comes from the pgvector extension, not from SQLAlchemy's
|
||||
# type system.
|
||||
op.execute(f"ALTER TABLE rule_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL")
|
||||
# HNSW for cosine distance — matches Vector.cosine_distance (`<=>`), so the
|
||||
# search is an indexed ORDER BY ... LIMIT k rather than a full scan.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX ix_rule_embeddings_embedding_hnsw
|
||||
ON rule_embeddings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_rule_embeddings_embedding_hnsw")
|
||||
op.drop_table("rule_embeddings")
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
|
||||
* map onto (milestone 307).
|
||||
*
|
||||
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
|
||||
* name the project gave it, and `canonical_id` only records which shared area
|
||||
* it is an instance of. An unmapped System is fully usable — the catalog is a
|
||||
* convergence aid, not a gate.
|
||||
*/
|
||||
import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client";
|
||||
|
||||
export interface CanonicalSystem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** The match key: lowercase, "&" folded to "and", punctuation collapsed. */
|
||||
slug: string;
|
||||
description: string | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested mapping. `basis` is the whole point of showing it:
|
||||
* - `exact` — the names differ only in spelling. Mechanical.
|
||||
* - `overlap` — they share a meaningful word. A judgment call the reviewer is
|
||||
* making, and it must never be presented as if it were the first.
|
||||
*/
|
||||
export interface CanonicalMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface MappingProposal {
|
||||
system_id: number;
|
||||
system_name: string;
|
||||
canonical_id: number;
|
||||
canonical_name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score: number;
|
||||
}
|
||||
|
||||
export async function listCanonicalSystems(): Promise<CanonicalSystem[]> {
|
||||
const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>(
|
||||
"/api/canonical-systems",
|
||||
);
|
||||
return data.canonical_systems;
|
||||
}
|
||||
|
||||
/** Admin only — a global list anyone can extend stops being shared. */
|
||||
export async function createCanonicalSystem(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<CanonicalSystem> {
|
||||
return apiPost("/api/canonical-systems", data);
|
||||
}
|
||||
|
||||
export async function updateCanonicalSystem(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
): Promise<CanonicalSystem> {
|
||||
return apiPatch(`/api/canonical-systems/${id}`, data);
|
||||
}
|
||||
|
||||
/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */
|
||||
export async function proposeMappings(projectId: number): Promise<MappingProposal[]> {
|
||||
const data = await apiGet<{ proposals: MappingProposal[] }>(
|
||||
`/api/projects/${projectId}/canonical-proposals`,
|
||||
);
|
||||
return data.proposals;
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping. `null` unmaps. */
|
||||
export async function mapSystem(
|
||||
systemId: number,
|
||||
canonicalId: number | null,
|
||||
): Promise<{ id: number; canonical_id: number | null }> {
|
||||
return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId });
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** How a rule reaches a session (milestone 307). */
|
||||
export type RuleTier = "always_on" | "conditional";
|
||||
|
||||
/**
|
||||
* A typed edge between two rules. Each kind exists because its absence forced
|
||||
* a workaround: merging two rules into one row, writing an override as a
|
||||
* near-copy, or leaving a local addendum with nothing to say it is one.
|
||||
*/
|
||||
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
||||
|
||||
export interface RuleRelation {
|
||||
id: number;
|
||||
kind: RuleRelationKind;
|
||||
/** The rule at the OTHER end. */
|
||||
rule_id: number;
|
||||
direction: "outgoing" | "incoming";
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Rulebook {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
@@ -26,35 +45,53 @@ export interface Rule {
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
statement: string;
|
||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||
when_to_apply: string;
|
||||
/**
|
||||
* always_on preloads into every session; conditional is reachable and
|
||||
* surfaced when its trigger fires. A rule with no tier set behaves as
|
||||
* always_on, which is how every rule behaved before this existed.
|
||||
*/
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
/** The note or task that caused this rule, if one was recorded. */
|
||||
arose_from_id: number | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
/** Present only when the rule has them (the server omits empty keys). */
|
||||
systems?: { id: number; name: string }[];
|
||||
relations?: RuleRelation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
||||
* age deliberately: a rule written before the capability it duplicates is
|
||||
* otherwise indistinguishable, at a glance, from one still doing work.
|
||||
*/
|
||||
export interface RuleHeader {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
tier: RuleTier;
|
||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
arose_from_id?: number;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
// Both lists are rule_brief's output — the SAME builder, so they are
|
||||
// described the same way here rather than as two hand-written shapes that
|
||||
// drift from it and from each other (which is what the server side had).
|
||||
rules: (RuleHeader & {
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
project_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
}[];
|
||||
})[];
|
||||
project_rules: RuleHeader[];
|
||||
suppressed_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -133,14 +170,39 @@ export async function getRule(id: number): Promise<Rule> {
|
||||
return apiGet(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
||||
/** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */
|
||||
export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
when_to_apply: string;
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
system_ids: number[];
|
||||
arose_from_id: number | null;
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
||||
}
|
||||
|
||||
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
|
||||
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
||||
return apiPatch(`/api/rules/${id}`, data);
|
||||
}
|
||||
|
||||
/** Draw a typed edge from one rule to another. Idempotent. */
|
||||
export async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
||||
): Promise<{ id: number }> {
|
||||
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
||||
}
|
||||
|
||||
export async function unrelateRules(relationId: number): Promise<void> {
|
||||
return apiDelete(`/api/rule-relations/${relationId}`);
|
||||
}
|
||||
|
||||
export async function deleteRule(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
}
|
||||
@@ -161,7 +223,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
|
||||
|
||||
export async function createProjectRule(
|
||||
projectId: number,
|
||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||
data: Partial<RuleWrite> & { statement: string },
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
|
||||
export interface System {
|
||||
id: number;
|
||||
project_id: number;
|
||||
name: string;
|
||||
/**
|
||||
* The global area this System is an instance of, or null. Null is a valid
|
||||
* resting state — a project-specific area should stay unmapped.
|
||||
*/
|
||||
canonical_id: number | null;
|
||||
description: string;
|
||||
color: string | null;
|
||||
status: "active" | "archived";
|
||||
@@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
||||
return data.systems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A created System, plus the catalog's answer about its name. An `exact`
|
||||
* catalog hit is applied by the server and arrives as a populated
|
||||
* `canonical_id`; an `overlap` is only OFFERED, and comes back here for the
|
||||
* caller to accept or ignore.
|
||||
*
|
||||
* A same-named System in this project is a 409 ApiError carrying
|
||||
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
|
||||
*/
|
||||
export interface CreatedSystem extends System {
|
||||
canonical_suggestion?: CanonicalMatch;
|
||||
}
|
||||
|
||||
export async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
): Promise<System> {
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
): Promise<CreatedSystem> {
|
||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { getProjectIssues } from "@/api/systems";
|
||||
import type { System, TaskLike } from "@/api/systems";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const store = useSystemsStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
@@ -19,14 +23,26 @@ const issues = ref<TaskLike[]>([]);
|
||||
const showCreate = ref(false);
|
||||
const newName = ref("");
|
||||
const newDescription = ref("");
|
||||
// The global area, chosen explicitly. A PICKER rather than a live matcher on
|
||||
// purpose: reproducing the server's slug rule in TypeScript would give this
|
||||
// feature two matchers to keep in step, which is the exact drift the catalog
|
||||
// exists to end. The server still applies an exact hit on submit.
|
||||
const newCanonicalId = ref<number | null>(null);
|
||||
const creating = ref(false);
|
||||
// An `overlap` the server offered after a create — an offer, never applied.
|
||||
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
|
||||
|
||||
// Edit state
|
||||
const editingId = ref<number | null>(null);
|
||||
const editName = ref("");
|
||||
const editDescription = ref("");
|
||||
const editCanonicalId = ref<number | null>(null);
|
||||
const savingEdit = ref(false);
|
||||
|
||||
// Mapping review
|
||||
const showReview = ref(false);
|
||||
const reviewBusy = ref<number | null>(null);
|
||||
|
||||
// Delete confirmation
|
||||
const deletingSystem = ref<System | null>(null);
|
||||
|
||||
@@ -37,6 +53,12 @@ const visibleSystems = computed(() =>
|
||||
showArchived.value ? systems.value : activeSystems.value,
|
||||
);
|
||||
|
||||
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
|
||||
|
||||
function areaName(system: System): string | null {
|
||||
return canon.byId(system.canonical_id)?.name ?? null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
error.value = null;
|
||||
try {
|
||||
@@ -49,6 +71,14 @@ async function load() {
|
||||
} catch {
|
||||
issues.value = [];
|
||||
}
|
||||
// Both fail soft: the catalog is a naming aid, and a review prompt that
|
||||
// cannot load must not take the Systems list down with it.
|
||||
await canon.fetchCatalog();
|
||||
try {
|
||||
await canon.fetchProposals(props.projectId);
|
||||
} catch {
|
||||
/* no proposals shown */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -58,12 +88,14 @@ function openCreate() {
|
||||
showCreate.value = true;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
showCreate.value = false;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
@@ -71,23 +103,60 @@ async function submitCreate() {
|
||||
if (!name || creating.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await store.createSystem(props.projectId, {
|
||||
const created = await store.createSystem(props.projectId, {
|
||||
name,
|
||||
description: newDescription.value.trim() || undefined,
|
||||
canonical_id: newCanonicalId.value ?? undefined,
|
||||
});
|
||||
cancelCreate();
|
||||
toast.show("System created");
|
||||
} catch {
|
||||
toast.show("Failed to create system", "error");
|
||||
if (created.canonical_suggestion) {
|
||||
// An overlap: shown as an offer beside the new System, never applied.
|
||||
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
|
||||
}
|
||||
toast.show(
|
||||
created.canonical_id
|
||||
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
|
||||
: "System created",
|
||||
);
|
||||
} catch (e) {
|
||||
// 409 = this project already has that System. Say WHICH one, so the
|
||||
// answer is actionable rather than "it didn't work".
|
||||
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSuggestion() {
|
||||
const pending = suggestion.value;
|
||||
if (!pending) return;
|
||||
suggestion.value = null;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
|
||||
await store.fetchSystems(props.projectId);
|
||||
toast.show(`Filed under ${pending.match.name}`);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProposal(systemId: number, canonicalId: number) {
|
||||
reviewBusy.value = systemId;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, systemId, canonicalId);
|
||||
await store.fetchSystems(props.projectId);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
} finally {
|
||||
reviewBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(system: System) {
|
||||
editingId.value = system.id;
|
||||
editName.value = system.name;
|
||||
editDescription.value = system.description;
|
||||
editCanonicalId.value = system.canonical_id;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -103,6 +172,12 @@ async function submitEdit(system: System) {
|
||||
name,
|
||||
description: editDescription.value.trim(),
|
||||
});
|
||||
// The mapping is a separate write with its own validation — one column,
|
||||
// one writer (services/canonical_systems.set_system_canonical).
|
||||
if (editCanonicalId.value !== system.canonical_id) {
|
||||
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
|
||||
await store.fetchSystems(props.projectId);
|
||||
}
|
||||
editingId.value = null;
|
||||
toast.show("System updated");
|
||||
} catch {
|
||||
@@ -161,6 +236,65 @@ async function confirmDelete() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Mapping review. Only appears when there is something to decide, and
|
||||
it says HOW MANY rather than nagging with a permanent banner. -->
|
||||
<div v-if="proposals.length" class="area-review">
|
||||
<button class="area-review-head" @click="showReview = !showReview">
|
||||
<span class="area-review-count">{{ proposals.length }}</span>
|
||||
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
|
||||
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<ul v-if="showReview" class="area-proposals">
|
||||
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
|
||||
<div class="area-proposal-text">
|
||||
<span class="area-proposal-name">{{ p.system_name }}</span>
|
||||
<span class="area-proposal-arrow" aria-hidden="true">→</span>
|
||||
<span class="area-proposal-target">{{ p.canonical_name }}</span>
|
||||
<!-- The basis is the decision the reviewer is making: `exact`
|
||||
differs only in spelling, `overlap` is a judgment call.
|
||||
Showing them identically is how a wrong mapping is waved
|
||||
through, so they never share a style. -->
|
||||
<span
|
||||
class="area-basis"
|
||||
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
|
||||
:title="
|
||||
p.basis === 'exact'
|
||||
? 'Same name up to spelling — safe to accept.'
|
||||
: 'Shares a word. Accept only if it is really the same area.'
|
||||
"
|
||||
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
|
||||
</div>
|
||||
<div class="area-proposal-actions">
|
||||
<button
|
||||
class="btn-primary btn-compact"
|
||||
:disabled="reviewBusy === p.system_id"
|
||||
@click="applyProposal(p.system_id, p.canonical_id)"
|
||||
>
|
||||
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="canon.dismissProposal(props.projectId, p.system_id)"
|
||||
>
|
||||
Not this
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- An overlap offered by the server after a create. Never applied. -->
|
||||
<div v-if="suggestion" class="area-offer">
|
||||
<span>
|
||||
Is this the same area as
|
||||
<strong>{{ suggestion.match.name }}</strong>?
|
||||
</span>
|
||||
<div class="area-proposal-actions">
|
||||
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
|
||||
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
@@ -189,6 +323,20 @@ async function confirmDelete() {
|
||||
placeholder="What is this subsystem responsible for? (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- .field-hint is the shared hint class beside .fs-input
|
||||
(components.css) — not restated scoped. -->
|
||||
<span class="field-hint">
|
||||
Files this system under an area shared by every project. Your name stays as you typed it.
|
||||
</span>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
@@ -240,6 +388,15 @@ async function confirmDelete() {
|
||||
placeholder="Description (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
@@ -264,6 +421,13 @@ async function confirmDelete() {
|
||||
:title="`${system.open_issue_count} open issue(s)`"
|
||||
>{{ system.open_issue_count }} open</span>
|
||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
|
||||
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
|
||||
This is the shared AREA this system is an instance of. -->
|
||||
<span
|
||||
v-if="areaName(system)"
|
||||
class="area-chip"
|
||||
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
|
||||
>{{ areaName(system) }}</span>
|
||||
</div>
|
||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||
</div>
|
||||
@@ -335,6 +499,91 @@ async function confirmDelete() {
|
||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Shared-area mapping (milestone 307) ──────────────────────────
|
||||
The review is a disclosure, not a banner: it exists only while there is
|
||||
something to decide, and collapses to one line until opened. */
|
||||
.area-review {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.area-review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.area-review-head:hover { color: var(--fs-text-primary); }
|
||||
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.area-review-count {
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
|
||||
|
||||
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-proposal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
|
||||
.area-proposal-name { color: var(--fs-text-primary); }
|
||||
.area-proposal-arrow { color: var(--fs-text-tertiary); }
|
||||
.area-proposal-target { color: var(--fs-accent); }
|
||||
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
|
||||
|
||||
/* The two bases must never look alike — one is mechanical, the other is the
|
||||
reviewer's judgment, and that difference is the whole decision. */
|
||||
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
|
||||
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
|
||||
.area-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-3);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-accent-faint);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
.area-select { box-sizing: border-box; width: 100%; }
|
||||
|
||||
.area-chip {
|
||||
font-size: 0.66rem;
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.btn-add-system {
|
||||
|
||||
@@ -27,7 +27,10 @@ const expandedRuleIds = ref<Set<number>>(new Set());
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
|
||||
const showProjectRuleForm = ref(false);
|
||||
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||||
const newProjectRule = ref({
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
||||
});
|
||||
|
||||
async function load() {
|
||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||
@@ -90,14 +93,20 @@ interface RulebookGroup {
|
||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||
const byRulebook = new Map<number, RulebookGroup>();
|
||||
for (const r of rules) {
|
||||
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
|
||||
// this list, so a null topic would be a server-side contradiction — skip
|
||||
// it rather than widen the group's type to accommodate a case that means
|
||||
// something is wrong upstream.
|
||||
if (r.topic_id === null) continue;
|
||||
const topicId = r.topic_id;
|
||||
let rb = byRulebook.get(r.rulebook_id);
|
||||
if (!rb) {
|
||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||
byRulebook.set(r.rulebook_id, rb);
|
||||
}
|
||||
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
|
||||
let topic = rb.topics.find((t) => t.topic_id === topicId);
|
||||
if (!topic) {
|
||||
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
||||
topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
|
||||
rb.topics.push(topic);
|
||||
}
|
||||
topic.rules.push(r);
|
||||
@@ -113,8 +122,13 @@ async function submitProjectRule() {
|
||||
title: newProjectRule.value.title.trim() || undefined,
|
||||
why: newProjectRule.value.why.trim() || undefined,
|
||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||
tier: newProjectRule.value.tier,
|
||||
});
|
||||
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||||
newProjectRule.value = {
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on",
|
||||
};
|
||||
showProjectRuleForm.value = false;
|
||||
await load();
|
||||
}
|
||||
@@ -219,6 +233,24 @@ watch(() => props.projectId, load);
|
||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="newProjectRule.when_to_apply"
|
||||
placeholder="When to apply — the trigger, not the instruction"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="tier-row">
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
||||
Always on
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
|
||||
Conditional
|
||||
</label>
|
||||
<span class="tier-hint">
|
||||
Conditional if you had to name a system, an artifact or a moment to state the trigger.
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="newProjectRule.why"
|
||||
placeholder="Why (optional) — the rationale"
|
||||
@@ -345,6 +377,11 @@ watch(() => props.projectId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.tier-row input { accent-color: var(--fs-accent); }
|
||||
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||
.chip-excluded .chip-remove { text-decoration: none; }
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const whenToApply = ref("");
|
||||
const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
|
||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||
|
||||
// The label a reader needs to judge an edge, not the stored token.
|
||||
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||||
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||||
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||||
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||||
};
|
||||
|
||||
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||||
}
|
||||
|
||||
function toggleSystem(id: number) {
|
||||
const at = systemIds.value.indexOf(id);
|
||||
if (at >= 0) systemIds.value.splice(at, 1);
|
||||
else systemIds.value.push(id);
|
||||
}
|
||||
|
||||
const isCreating = ref(props.ruleId === null);
|
||||
|
||||
async function load() {
|
||||
@@ -20,15 +45,22 @@ async function load() {
|
||||
if (r) {
|
||||
title.value = r.title;
|
||||
statement.value = r.statement;
|
||||
whenToApply.value = r.when_to_apply || "";
|
||||
tier.value = r.tier || "always_on";
|
||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
}
|
||||
} else {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
whenToApply.value = "";
|
||||
tier.value = "always_on";
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -36,16 +68,21 @@ async function save() {
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
const fields = {
|
||||
title: title.value,
|
||||
statement: statement.value,
|
||||
when_to_apply: whenToApply.value,
|
||||
tier: tier.value,
|
||||
// Always sent, so clearing the last area actually clears it — the server
|
||||
// reads a list as "these ARE the areas now".
|
||||
system_ids: systemIds.value,
|
||||
why: why.value,
|
||||
how_to_apply: howToApply.value,
|
||||
};
|
||||
if (isCreating.value && props.topicId !== null) {
|
||||
await store.createRule(props.topicId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
await store.createRule(props.topicId, fields);
|
||||
} else if (props.ruleId !== null) {
|
||||
await store.updateRule(props.ruleId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
await store.updateRule(props.ruleId, fields);
|
||||
}
|
||||
emit("close");
|
||||
}
|
||||
@@ -77,6 +114,69 @@ watch(() => props.ruleId, load);
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
</label>
|
||||
<label>
|
||||
When to apply
|
||||
<textarea
|
||||
v-model="whenToApply"
|
||||
rows="2"
|
||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset class="tier">
|
||||
<legend>How it reaches a session</legend>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="always_on" />
|
||||
<span>
|
||||
<strong>Always on</strong>
|
||||
— loaded into every session.
|
||||
</span>
|
||||
</label>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="conditional" />
|
||||
<span>
|
||||
<strong>Conditional</strong>
|
||||
— arrives when its trigger fires.
|
||||
</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
||||
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
||||
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
||||
it needs to be.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="canon.catalog.length" class="areas">
|
||||
<legend>Areas this rule is about</legend>
|
||||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="systemIds.includes(entry.id)"
|
||||
@change="toggleSystem(entry.id)"
|
||||
/>
|
||||
<span>{{ entry.name }}</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
What lets this rule reach a project working in that area.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<section v-if="relations.length" class="relations">
|
||||
<h3>Related rules</h3>
|
||||
<ul>
|
||||
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||||
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||||
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="tier-test">
|
||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||
cited, surfaced or suppressed a clause at a time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<label>
|
||||
Why
|
||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||
@@ -118,6 +218,19 @@ input, textarea {
|
||||
padding: 0.5rem; font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
|
||||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.relation-kind { color: var(--fs-accent); }
|
||||
.relation-target { color: var(--fs-text-primary); }
|
||||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||
.trash:hover, .close:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -13,8 +13,17 @@ const emit = defineEmits<{
|
||||
<header><h2>Rules</h2></header>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||
<div class="title">{{ r.title }}</div>
|
||||
<div class="title">
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
</div>
|
||||
<div class="statement">{{ r.statement }}</div>
|
||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
|
||||
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||
@@ -35,5 +44,19 @@ li {
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||
.tier-chip {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
font-style: normal;
|
||||
font-size: 0.62rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.new-rule { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import * as api from "@/api/canonicalSystems";
|
||||
import type { CanonicalSystem, MappingProposal } from "@/api/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
/**
|
||||
* The global area catalog (milestone 307). Shared by every project, so it is
|
||||
* fetched ONCE per session rather than per project — the whole point of the
|
||||
* table is that it is the same list everywhere.
|
||||
*/
|
||||
export const useCanonicalSystemsStore = defineStore("canonicalSystems", () => {
|
||||
const catalog = ref<CanonicalSystem[]>([]);
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
const proposalsByProject = ref<Record<number, MappingProposal[]>>({});
|
||||
|
||||
async function fetchCatalog(force = false) {
|
||||
if (loaded.value && !force) return catalog.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
catalog.value = await api.listCanonicalSystems();
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
// A naming aid must never break the screen it rides on — an empty
|
||||
// catalog degrades the suggestion, it does not fail the form.
|
||||
catalog.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return catalog.value;
|
||||
}
|
||||
|
||||
function byId(id: number | null): CanonicalSystem | undefined {
|
||||
if (id == null) return undefined;
|
||||
return catalog.value.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
async function fetchProposals(projectId: number) {
|
||||
proposalsByProject.value[projectId] = await api.proposeMappings(projectId);
|
||||
return proposalsByProject.value[projectId];
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping, then drop it from the pending proposals. */
|
||||
async function mapSystem(projectId: number, systemId: number, canonicalId: number | null) {
|
||||
try {
|
||||
await api.mapSystem(systemId, canonicalId);
|
||||
} catch (e) {
|
||||
useToastStore().show(apiErrorMessage(e, "Failed to map system"), "error");
|
||||
throw e;
|
||||
}
|
||||
dismissProposal(projectId, systemId);
|
||||
}
|
||||
|
||||
/** Remove a proposal from the pending list without writing anything. */
|
||||
function dismissProposal(projectId: number, systemId: number) {
|
||||
const list = proposalsByProject.value[projectId];
|
||||
if (list) {
|
||||
proposalsByProject.value[projectId] = list.filter((p) => p.system_id !== systemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntry(data: { name: string; description?: string }) {
|
||||
const entry = await api.createCanonicalSystem(data);
|
||||
catalog.value.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function updateEntry(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
) {
|
||||
const entry = await api.updateCanonicalSystem(id, data);
|
||||
const idx = catalog.value.findIndex((c) => c.id === id);
|
||||
if (idx >= 0) catalog.value[idx] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
loaded,
|
||||
loading,
|
||||
proposalsByProject,
|
||||
fetchCatalog,
|
||||
byId,
|
||||
fetchProposals,
|
||||
mapSystem,
|
||||
dismissProposal,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
};
|
||||
});
|
||||
@@ -35,9 +35,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
async function fetchRules(topicId: number) {
|
||||
try {
|
||||
const rules = await api.listRules({ topic_id: topicId });
|
||||
rulesByTopic.value[topicId] = rules.map((r) => ({
|
||||
id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id,
|
||||
}));
|
||||
rulesByTopic.value[topicId] = rules.map(toHeader);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load rules", "error");
|
||||
throw e;
|
||||
@@ -98,24 +96,58 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
delete rulesByTopic.value[id];
|
||||
}
|
||||
|
||||
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
|
||||
/**
|
||||
* A list row built from a full rule. The row shape is the server's
|
||||
* rule_brief, so every field it carries has to be mirrored here or the two
|
||||
* disagree the moment a list is patched locally instead of re-fetched.
|
||||
*/
|
||||
function toHeader(rule: Rule): api.RuleHeader {
|
||||
return {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
statement: rule.statement,
|
||||
topic_id: rule.topic_id,
|
||||
tier: rule.tier,
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || undefined,
|
||||
arose_from_id: rule.arose_from_id ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
|
||||
const rule = await api.createRule(topicId, data);
|
||||
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
||||
rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id });
|
||||
rulesByTopic.value[topicId].push(toHeader(rule));
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function updateRule(id: number, data: Partial<Pick<Rule, "title" | "statement" | "why" | "how_to_apply" | "order_index">>) {
|
||||
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
|
||||
const rule = await api.updateRule(id, data);
|
||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id };
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
|
||||
) {
|
||||
await api.relateRules(fromRuleId, data);
|
||||
// Re-read rather than patching locally: the edge reads from BOTH ends, so
|
||||
// the far rule's relations changed too and a local splice would show only
|
||||
// half of what just happened.
|
||||
await fetchRule(fromRuleId);
|
||||
}
|
||||
|
||||
async function unrelateRules(relationId: number, refreshRuleId: number) {
|
||||
await api.unrelateRules(relationId);
|
||||
await fetchRule(refreshRuleId);
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await api.deleteRule(id);
|
||||
if (currentRule.value?.id === id) currentRule.value = null;
|
||||
@@ -129,6 +161,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
||||
|
||||
async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
) {
|
||||
const system = await api.createSystem(projectId, data);
|
||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -12,6 +13,62 @@ import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
|
||||
// ── Shared areas (milestone 307) ────────────────────────────────────────
|
||||
// The global vocabulary a project's Systems map onto. Admin-only to WRITE —
|
||||
// a global list anyone can extend stops being a shared list — but every user
|
||||
// reads it, which is why the catalog lives in a store rather than here.
|
||||
const canonStore = useCanonicalSystemsStore();
|
||||
const newAreaName = ref("");
|
||||
const newAreaDescription = ref("");
|
||||
const creatingArea = ref(false);
|
||||
const editingAreaId = ref<number | null>(null);
|
||||
const editAreaName = ref("");
|
||||
const editAreaDescription = ref("");
|
||||
const savingArea = ref(false);
|
||||
|
||||
async function createArea() {
|
||||
const name = newAreaName.value.trim();
|
||||
if (!name || creatingArea.value) return;
|
||||
creatingArea.value = true;
|
||||
try {
|
||||
await canonStore.createEntry({
|
||||
name,
|
||||
description: newAreaDescription.value.trim() || undefined,
|
||||
});
|
||||
newAreaName.value = "";
|
||||
newAreaDescription.value = "";
|
||||
toastStore.show("Area added");
|
||||
} catch (e) {
|
||||
// A 409 means an area with the same match key already exists — say which,
|
||||
// because "CI and Release" vs "CI & Release" looks like a different name.
|
||||
toastStore.show(apiErrorMessage(e, "Failed to add area"), "error");
|
||||
} finally {
|
||||
creatingArea.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditArea(id: number, name: string, description: string | null) {
|
||||
editingAreaId.value = id;
|
||||
editAreaName.value = name;
|
||||
editAreaDescription.value = description ?? "";
|
||||
}
|
||||
|
||||
async function saveArea() {
|
||||
const id = editingAreaId.value;
|
||||
const name = editAreaName.value.trim();
|
||||
if (id == null || !name || savingArea.value) return;
|
||||
savingArea.value = true;
|
||||
try {
|
||||
await canonStore.updateEntry(id, { name, description: editAreaDescription.value.trim() });
|
||||
editingAreaId.value = null;
|
||||
toastStore.show("Area updated");
|
||||
} catch (e) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to update area"), "error");
|
||||
} finally {
|
||||
savingArea.value = false;
|
||||
}
|
||||
}
|
||||
const userTimezone = ref("");
|
||||
const savingTimezone = ref(false);
|
||||
const timezoneSaved = ref(false);
|
||||
@@ -134,7 +191,7 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -143,6 +200,7 @@ function _loadTabContent(tab: string) {
|
||||
if (tab === "users") loadUsersPanel();
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||
}
|
||||
if (tab === "apikeys") { fetchApiKeys(); }
|
||||
}
|
||||
@@ -1212,7 +1270,7 @@ async function deleteUser(userId: number) {
|
||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||
<div class="sidebar-group-label">Admin</div>
|
||||
<button
|
||||
v-for="tab in ['config', 'users', 'groups', 'logs']"
|
||||
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -2263,6 +2321,78 @@ async function deleteUser(userId: number) {
|
||||
</div>
|
||||
|
||||
<!-- ── Users ── -->
|
||||
<!-- ── Shared areas ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'areas'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Shared areas</h2>
|
||||
<p class="field-hint">
|
||||
The vocabulary every project's Systems can be filed under, so the same word means the
|
||||
same thing everywhere. A project keeps its own name for an area — mapping records which
|
||||
shared area it is, it never renames anything. Editing a name here re-derives its match
|
||||
key, so existing mappings are kept but future name matching follows the new spelling.
|
||||
</p>
|
||||
|
||||
<ul class="area-admin-list">
|
||||
<li v-for="entry in canonStore.catalog" :key="entry.id" class="area-admin-row">
|
||||
<template v-if="editingAreaId === entry.id">
|
||||
<form class="area-admin-form" @submit.prevent="saveArea">
|
||||
<input v-model="editAreaName" class="fs-input" aria-label="Area name" />
|
||||
<textarea
|
||||
v-model="editAreaDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
aria-label="Area description"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editAreaName.trim() || savingArea">
|
||||
{{ savingArea ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="editingAreaId = null">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="area-admin-body">
|
||||
<div class="area-admin-name-row">
|
||||
<span class="area-admin-name">{{ entry.name }}</span>
|
||||
<code class="area-admin-slug" title="The match key. Names that reduce to this are the same area.">{{ entry.slug }}</code>
|
||||
</div>
|
||||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="startEditArea(entry.id, entry.name, entry.description)"
|
||||
>Edit</button>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!canonStore.catalog.length && !canonStore.loading" class="settings-empty">
|
||||
No areas yet.
|
||||
</p>
|
||||
|
||||
<form class="area-admin-form area-admin-create" @submit.prevent="createArea">
|
||||
<input
|
||||
v-model="newAreaName"
|
||||
class="fs-input"
|
||||
placeholder="New area name (e.g. Search & Indexing)"
|
||||
aria-label="New area name"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newAreaDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
placeholder="What belongs in this area? One paragraph — a bare name is never enough."
|
||||
aria-label="New area description"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newAreaName.trim() || creatingArea">
|
||||
{{ creatingArea ? "Adding…" : "Add area" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
@@ -3401,4 +3531,34 @@ async function deleteUser(userId: number) {
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Shared areas (milestone 307) ──────────────────────────────────
|
||||
The slug is shown deliberately: it is what decides whether two names are
|
||||
the same area, and an admin renaming an entry needs to see it move. */
|
||||
.area-admin-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-admin-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
padding: var(--fs-space-3);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-admin-body { flex: 1; min-width: 0; }
|
||||
.area-admin-name-row { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.area-admin-name { color: var(--fs-text-primary); }
|
||||
.area-admin-slug {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.area-admin-desc { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||||
.area-admin-create { margin-top: var(--fs-space-4); }
|
||||
.area-admin-actions { display: flex; gap: 0.4rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.46",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"version": "0.1.47",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
"type": "http",
|
||||
"url": "${user_config.api_endpoint}/mcp",
|
||||
"headers": { "Authorization": "Bearer ${user_config.api_token}" }
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${user_config.api_token}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"userConfig": {
|
||||
@@ -19,7 +23,7 @@
|
||||
"api_token": {
|
||||
"type": "string",
|
||||
"title": "Scribe API key",
|
||||
"description": "An fmcp_ API key from Settings → API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
|
||||
"description": "An fmcp_ API key from Settings \u2192 API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
|
||||
"sensitive": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,17 +174,24 @@ mkdir -p "$state_dir" 2>/dev/null || true
|
||||
# (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
|
||||
# shapes being written. Keyed by that token, not a note id, so it dedups on
|
||||
# its own file and a family is named once per session, not at every edit.
|
||||
#
|
||||
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
|
||||
# file for the same reason as the others — a rule named once should not be
|
||||
# re-offered on every subsequent write in the session.
|
||||
idfile=""
|
||||
syncfile=""
|
||||
derivefile=""
|
||||
rulefile=""
|
||||
exclude_q=""
|
||||
sync_exclude_q=""
|
||||
derive_exclude_q=""
|
||||
rule_exclude_q=""
|
||||
if [ -n "$session_id" ]; then
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||
rulefile="$state_dir/${safe_sid}.rules.ids"
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
@@ -197,6 +204,10 @@ if [ -n "$session_id" ]; then
|
||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
||||
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||
fi
|
||||
if [ -f "$rulefile" ]; then
|
||||
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
||||
@@ -206,7 +217,7 @@ fi
|
||||
reached=1
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||
unreached_context=""
|
||||
if [ "$reached" = 1 ]; then
|
||||
scribe_reached "$state_dir" "${safe_sid:-nosession}"
|
||||
@@ -227,6 +238,9 @@ if [ -n "$body" ]; then
|
||||
if [ -n "$syncfile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||
fi
|
||||
if [ -n "$rulefile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
|
||||
fi
|
||||
if [ -n "$derivefile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
||||
fi
|
||||
|
||||
+9
-1
@@ -30,6 +30,7 @@ from scribe.routes.design_systems import design_systems_bp
|
||||
from scribe.routes.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
from scribe.routes.canonical_systems import canonical_systems_bp
|
||||
from scribe.routes.snippets import snippets_bp
|
||||
from scribe.routes.webhooks import webhooks_bp
|
||||
from scribe.mcp import mount_mcp
|
||||
@@ -95,6 +96,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(systems_bp)
|
||||
app.register_blueprint(canonical_systems_bp)
|
||||
app.register_blueprint(snippets_bp)
|
||||
app.register_blueprint(webhooks_bp)
|
||||
|
||||
@@ -159,7 +161,7 @@ def create_app() -> Quart:
|
||||
import asyncio
|
||||
|
||||
from scribe.services.auth import start_auth_token_retention_loop
|
||||
from scribe.services.embeddings import backfill_note_embeddings
|
||||
from scribe.services.embeddings import backfill_note_embeddings, backfill_rule_embeddings
|
||||
from scribe.services.logging import start_log_retention_loop
|
||||
from scribe.services.notifications import start_notification_loop
|
||||
|
||||
@@ -174,6 +176,12 @@ def create_app() -> Quart:
|
||||
await backfill_note_embeddings()
|
||||
except Exception:
|
||||
logger.warning("Embedding backfill failed", exc_info=True)
|
||||
# Rules got vectors in milestone 307; every rule written before it
|
||||
# has none, so this is the pass that makes them findable at all.
|
||||
try:
|
||||
await backfill_rule_embeddings()
|
||||
except Exception:
|
||||
logger.warning("Rule embedding backfill failed", exc_info=True)
|
||||
# Snippets written before migration 0070 have no `notes.data` mirror,
|
||||
# and the location reverse lookup queries that column — an unfilled
|
||||
# row would read as "no snippet here" rather than as a gap. Separate
|
||||
|
||||
@@ -91,6 +91,9 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
"get_system", "list_systems", "list_system_records",
|
||||
# The global area catalog and its mapping REPORT — propose writes nothing;
|
||||
# map_system_to_canonical is the separate, explicitly-called write.
|
||||
"list_canonical_systems", "propose_canonical_mappings",
|
||||
# Reports on the corpus. Reads only — the merge or supersession each
|
||||
# suggests is a separate, explicitly-called write.
|
||||
"find_duplicate_snippets", "find_duplicate_records",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""MCP tools for the Scribe Rulebook system.
|
||||
|
||||
Sixteen tools: rulebook/topic/rule CRUD + subscription management. Thin
|
||||
wrappers over services/rulebooks.py — ownership is enforced in the service.
|
||||
Rulebook / topic / rule CRUD, subscription management, and the rule-to-rule
|
||||
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
|
||||
service, and the record shape comes from rule_brief / rule_detail there rather
|
||||
than being rebuilt here.
|
||||
|
||||
(The header used to say "Sixteen tools" and had been wrong for two milestones;
|
||||
the count lives in the registration test, which fails when it drifts.)
|
||||
|
||||
Destructive ops (delete_*) require confirmed=True; otherwise return a
|
||||
preview-style warning. Mirrors the pattern in delete_event and the design
|
||||
@@ -195,8 +200,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
|
||||
|
||||
def _rule_summary(r) -> dict:
|
||||
"""The list-row shape for a rule: what an agent needs to APPLY it. The
|
||||
full record (why, how_to_apply, timestamps) is get_rule's job."""
|
||||
return {"id": r.id, "title": r.title, "statement": r.statement, "topic_id": r.topic_id}
|
||||
full record (why, how_to_apply, timestamps) is get_rule's job.
|
||||
|
||||
One line, because the shape itself lives in the service — this was one of
|
||||
three hand-written copies that had already drifted apart (note 3026).
|
||||
"""
|
||||
return rulebooks_svc.rule_brief(r)
|
||||
|
||||
|
||||
async def list_rules(
|
||||
@@ -227,6 +236,13 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
|
||||
Call this at session start. Treat the returned rules as binding for the
|
||||
session — they apply regardless of which project (if any) is in scope.
|
||||
|
||||
Returns the ALWAYS-ON tier only (milestone 307). A `conditional` rule is
|
||||
still binding when it applies; it just is not resident — it reaches a
|
||||
session through enter_project (when the project works in an area the rule
|
||||
is tagged to) or through search(content_type="rule"). Nothing here is a
|
||||
behaviour change until rules are actually re-tiered: `tier` defaults to
|
||||
always_on, so an existing rulebook returns exactly what it always did.
|
||||
Pair with get_project(id).applicable_rules when working on a specific
|
||||
project to also load that project's subscription-derived rules.
|
||||
|
||||
@@ -242,18 +258,25 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
|
||||
|
||||
async def get_rule(rule_id: int) -> dict:
|
||||
"""Fetch a rule by id — full statement + why + how_to_apply."""
|
||||
"""Fetch a rule by id — full statement + why + how_to_apply.
|
||||
|
||||
Also carries what a listing leaves out: the global `systems` this rule is
|
||||
about, and its `relations`. Read the relations before acting on the rule —
|
||||
a rule with a `co_surfaces` edge is half of a shape, and an `overrides`
|
||||
edge means one of the pair is not in force here.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||
if rule is None:
|
||||
raise ValueError(f"rule {rule_id} not found")
|
||||
return rule.to_dict()
|
||||
return await rulebooks_svc.rule_detail(uid, rule)
|
||||
|
||||
|
||||
async def create_rule(
|
||||
topic_id: int, title: str, statement: str,
|
||||
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
force: bool = False,
|
||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0, force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
||||
|
||||
@@ -273,10 +296,36 @@ async def create_rule(
|
||||
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
|
||||
is a standing instruction about how to work and nothing else can hold it.
|
||||
|
||||
ONE RULE = ONE THING YOU COULD VIOLATE. If a clause can be broken on its
|
||||
own, and fixing that breakage doesn't require the neighbouring clauses, it
|
||||
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
|
||||
(kind="co_surfaces"), never merged into one row: a merged rule cannot be
|
||||
cited, surfaced or suppressed a clause at a time, and it grows without
|
||||
limit because adding to it is always cheaper than adding a rule.
|
||||
|
||||
Args:
|
||||
topic_id: The topic to attach the rule to.
|
||||
title: A short imperative title (e.g. "dev is home").
|
||||
statement: The actionable instruction (required). 1-2 sentences.
|
||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||
instruction. State the moment or the material: "before any git
|
||||
push", "when adding a value to a CHECK-gated column", "when a
|
||||
release is being cut". Write it even though the parameter is
|
||||
optional: it decides the tier below, it is how the rule is found
|
||||
when it matters, and a rule nobody can place is a rule nobody
|
||||
applies.
|
||||
tier: "always_on" (default) or "conditional".
|
||||
The test: can you name the trigger WITHOUT naming a system, an
|
||||
artifact type or a moment? If the honest answer is "whenever you
|
||||
are working", it is always_on. If you had to name something, it is
|
||||
conditional — and conditional costs nothing when it is irrelevant,
|
||||
which is what lets it be as long as it needs to be.
|
||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||
rule is about. This is what lets a rule reach a project that is
|
||||
working in that area, so a CI rule surfaces on a CI change.
|
||||
arose_from_id: The note or task that CAUSED this rule (an incident, a
|
||||
decision). Prefer this over naming the record inside `why`, which
|
||||
cannot be followed and does not survive a rewording.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
order_index: Display order within the topic (default 0).
|
||||
@@ -291,16 +340,18 @@ async def create_rule(
|
||||
return dedup_svc.duplicate_response(dup, "rule")
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic_id=topic_id, user_id=uid,
|
||||
title=title, statement=statement,
|
||||
title=title, statement=statement, when_to_apply=when_to_apply,
|
||||
tier=tier, arose_from_id=arose_from_id,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
)
|
||||
return rule.to_dict()
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
|
||||
async def create_project_rule(
|
||||
project_id: int, statement: str, title: str = "",
|
||||
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
force: bool = False,
|
||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0, force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a rule scoped to a single project (no rulebook needed).
|
||||
|
||||
@@ -312,11 +363,24 @@ async def create_project_rule(
|
||||
the rule is returned in get_project's applicable_rules (under
|
||||
project_rules) and in list_rules(project_id=...).
|
||||
|
||||
ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that
|
||||
STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then
|
||||
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
|
||||
connected instead of drifting into a contradiction nobody notices. A rule
|
||||
that merely adds local detail to an inherited one uses "elaborates".
|
||||
|
||||
Args:
|
||||
project_id: The project to attach the rule to.
|
||||
statement: The actionable instruction (required). 1-2 sentences.
|
||||
title: Short imperative title. If empty, derived from the first ~50
|
||||
characters of statement.
|
||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||
instruction. See create_rule; it decides the tier and it is how
|
||||
the rule is found at the moment it matters.
|
||||
tier: "always_on" (default) or "conditional" — see create_rule.
|
||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||
rule is about.
|
||||
arose_from_id: The note or task that CAUSED this rule.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
order_index: Display order within the project's rule list (default 0).
|
||||
@@ -332,23 +396,36 @@ async def create_project_rule(
|
||||
return dedup_svc.duplicate_response(dup, "rule")
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id, user_id=uid,
|
||||
title=derived_title, statement=statement,
|
||||
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
||||
tier=tier, arose_from_id=arose_from_id,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
)
|
||||
return rule.to_dict()
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
|
||||
async def update_rule(
|
||||
rule_id: int, title: str = "", statement: str = "",
|
||||
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged."""
|
||||
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
|
||||
|
||||
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
|
||||
a rule stops being preloaded into every session and starts arriving when it
|
||||
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if statement:
|
||||
fields["statement"] = statement
|
||||
if when_to_apply:
|
||||
fields["when_to_apply"] = when_to_apply
|
||||
if tier:
|
||||
fields["tier"] = tier
|
||||
if arose_from_id:
|
||||
fields["arose_from_id"] = arose_from_id
|
||||
if why:
|
||||
fields["why"] = why
|
||||
if how_to_apply:
|
||||
@@ -358,7 +435,7 @@ async def update_rule(
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
if rule is None:
|
||||
raise ValueError(f"rule {rule_id} not found")
|
||||
return rule.to_dict()
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
|
||||
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
||||
@@ -496,12 +573,59 @@ async def unsuppress_topic_for_project(
|
||||
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||
|
||||
|
||||
|
||||
|
||||
async def relate_rules(
|
||||
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
||||
) -> dict:
|
||||
"""Draw a typed edge between two rules. Both must be yours.
|
||||
|
||||
Reach for this INSTEAD of merging or duplicating:
|
||||
|
||||
- kind="co_surfaces" — these two fail together, so they must arrive
|
||||
together. Use it when you are tempted to fold one rule into another
|
||||
because "either could surface without the other": that instinct is
|
||||
right and merging is the wrong fix, because a merged rule cannot be
|
||||
cited, suppressed or surfaced a clause at a time. Symmetric — draw it
|
||||
once, it reads from both ends.
|
||||
- kind="overrides" — this rule supersedes that one for its scope. Use it
|
||||
when a project rule is stricter than, or replaces, an inherited one,
|
||||
instead of writing a near-copy that will drift from its parent.
|
||||
- kind="elaborates" — this rule adds local specifics to that one, and
|
||||
should arrive with it rather than instead of it.
|
||||
|
||||
Idempotent: re-drawing an existing edge returns it.
|
||||
|
||||
Args:
|
||||
note: WHY the edge holds. Worth writing for the same reason a rule
|
||||
carries `why` — a later reader deciding whether it still applies
|
||||
needs the reasoning, not just the fact.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
relation = await rulebooks_svc.add_rule_relation(
|
||||
uid, from_rule_id, to_rule_id, kind, note,
|
||||
)
|
||||
if relation is None:
|
||||
raise ValueError(
|
||||
f"rule {from_rule_id} or {to_rule_id} not found (both must be yours)"
|
||||
)
|
||||
return relation.to_dict()
|
||||
|
||||
|
||||
async def unrelate_rules(relation_id: int) -> dict:
|
||||
"""Remove one edge between rules (from relate_rules / get_rule.relations)."""
|
||||
uid = current_user_id()
|
||||
if not await rulebooks_svc.remove_rule_relation(uid, relation_id):
|
||||
raise ValueError(f"relation {relation_id} not found")
|
||||
return {"deleted": relation_id}
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||
list_topics, create_topic, update_topic, delete_topic,
|
||||
list_rules, list_always_on_rules, get_rule,
|
||||
create_rule, create_project_rule, update_rule, delete_rule,
|
||||
relate_rules, unrelate_rules,
|
||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||
|
||||
@@ -11,10 +11,44 @@ import time
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services.access import owner_names_for
|
||||
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
|
||||
from scribe.services.embeddings import (
|
||||
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
|
||||
)
|
||||
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
|
||||
|
||||
|
||||
async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
"""Rules by meaning — a separate result shape because a rule IS different.
|
||||
|
||||
A rule hit carries `why` and `how_to_apply`: they are the operational half
|
||||
of a rule and the session-start payload never includes them, so a caller
|
||||
who went looking should get the whole thing rather than a summary they then
|
||||
have to re-fetch.
|
||||
|
||||
Rules are not project-scoped the way notes are (a family rule belongs to no
|
||||
project), so `project_id` and `system_id` do not apply here.
|
||||
"""
|
||||
raw = await semantic_search_rules(uid, q, limit=limit)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"id": rule.id,
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"when_to_apply": rule.when_to_apply or "",
|
||||
"tier": rule.tier,
|
||||
"why": rule.why or "",
|
||||
"how_to_apply": rule.how_to_apply or "",
|
||||
"topic_id": rule.topic_id,
|
||||
"project_id": rule.project_id,
|
||||
"similarity": float(score),
|
||||
}
|
||||
for score, rule in raw
|
||||
],
|
||||
"total": len(raw),
|
||||
}
|
||||
|
||||
|
||||
async def search(
|
||||
q: str,
|
||||
content_type: str = "all",
|
||||
@@ -33,7 +67,13 @@ async def search(
|
||||
|
||||
Args:
|
||||
q: search query string.
|
||||
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
|
||||
content_type: 'all' (default), 'note' (notes only), 'task' (tasks
|
||||
only), or 'rule' (RULES only — the operator's standing
|
||||
instructions, searchable by meaning since milestone 307).
|
||||
Reach for 'rule' when you want to know whether a standing
|
||||
instruction covers something: "is there a rule about release
|
||||
tagging?". A hit carries the rule's `why` and `how_to_apply`,
|
||||
which the session-start payload does not.
|
||||
limit: maximum number of results (1-50).
|
||||
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
|
||||
whenever a project is in scope (the one you entered with
|
||||
@@ -56,6 +96,8 @@ async def search(
|
||||
"""
|
||||
uid = current_user_id()
|
||||
limit = max(1, min(limit, 50))
|
||||
if content_type == "rule":
|
||||
return await _search_rules(uid, q, limit)
|
||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||
t0 = time.perf_counter()
|
||||
raw = await semantic_search_notes(
|
||||
|
||||
+124
-30
@@ -13,6 +13,7 @@ Sentinels (match the milestone/task tool conventions):
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import canonical_systems as canonical_systems_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
@@ -30,10 +31,9 @@ _BOOTSTRAP_TITLES = 6
|
||||
# design (rule #115): archetypes any codebase could have, never one
|
||||
# install's subsystems. Mint freely beyond the list; the duplicate gate
|
||||
# guards sprawl.
|
||||
# The standard vocabulary lives with the service (services/systems.
|
||||
# STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this
|
||||
# ask names it, one list for both.
|
||||
_STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS)
|
||||
# The standard vocabulary lives in the GLOBAL canonical catalog since
|
||||
# milestone 307 — the inception seed mints it and this ask names it, one list
|
||||
# for both, now a table so a rule can reference an area by id (note 3026).
|
||||
|
||||
|
||||
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
@@ -67,6 +67,17 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
titles = "; ".join(
|
||||
'"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
|
||||
)
|
||||
try:
|
||||
standard = ", ".join(name for name, _charter in await systems_svc.standard_systems())
|
||||
except Exception:
|
||||
standard = ""
|
||||
# An install whose catalog is empty still gets a usable ask — the standard
|
||||
# names are an aid, not the point of the question.
|
||||
standard_line = (
|
||||
"Where an area fits a standard name, use it verbatim so it means the "
|
||||
f"same thing in every project: {standard}. Mint freely beyond that "
|
||||
"list — the duplicate gate guards sprawl. "
|
||||
) if standard else "The duplicate gate guards sprawl. "
|
||||
return (
|
||||
f"This project has {total} records and NO Systems modelled — none of "
|
||||
"them can be tagged to an area, so recurring problem-spots stay "
|
||||
@@ -74,10 +85,8 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
"asking permission — creating Systems is your call, not an approval "
|
||||
f"flow. From the areas the records themselves name (recent: {titles}), "
|
||||
"create_system 3-6 Systems, each with a one-paragraph charter, then "
|
||||
"tag this record (system_ids=[...]). Where an area fits a standard "
|
||||
f"name, use it verbatim so it means the same thing in every project: "
|
||||
f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
|
||||
"duplicate gate guards sprawl. This ask repeats until the first "
|
||||
f"tag this record (system_ids=[...]). {standard_line}"
|
||||
"This ask repeats until the first "
|
||||
"System exists; answering it once retires it for every future record."
|
||||
)
|
||||
|
||||
@@ -188,36 +197,60 @@ async def create_system(
|
||||
normalized name already exists in this project (archived included), the
|
||||
call returns {"duplicate": true, "existing_id": ...} instead of creating —
|
||||
tag records to that one, or update_system it if its charter needs work.
|
||||
|
||||
Also mapped against the GLOBAL area catalog, so the same word means the
|
||||
same thing in every project (milestone 307). A name that IS a catalog area
|
||||
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
|
||||
the response says so. A name that merely RESEMBLES one comes back with
|
||||
`canonical_suggestion` — an offer, not a decision: apply it with
|
||||
map_system_to_canonical if it really is that area, ignore it if this is a
|
||||
project-specific area. Either way the System is created; the catalog never
|
||||
blocks a name.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
norm = " ".join(name.split()).lower()
|
||||
if norm:
|
||||
try:
|
||||
existing = await systems_svc.list_systems(
|
||||
uid, project_id, include_archived=True
|
||||
)
|
||||
except Exception:
|
||||
existing = []
|
||||
for s in existing:
|
||||
if " ".join(s.name.split()).lower() == norm:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": s.id,
|
||||
"message": (
|
||||
f"System '{s.name}' (#{s.id}) already covers this area "
|
||||
"in this project. Tag records to it with system_ids, "
|
||||
"or update_system it if the charter needs revising — "
|
||||
"a second System with the same name would split the "
|
||||
"area's records across two piles."
|
||||
),
|
||||
}
|
||||
assessment = await systems_svc.assess_system_name(uid, project_id, name)
|
||||
duplicate = assessment["duplicate"]
|
||||
if duplicate:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": duplicate["id"],
|
||||
"message": (
|
||||
f"System '{duplicate['name']}' (#{duplicate['id']}) already "
|
||||
"covers this area in this project. Tag records to it with "
|
||||
"system_ids, or update_system it if the charter needs "
|
||||
"revising — a second System with the same name would split "
|
||||
"the area's records across two piles."
|
||||
),
|
||||
}
|
||||
# An exact match is mechanical, so it is applied; an overlap is a judgment
|
||||
# call, so it is only offered (see services/canonical_systems).
|
||||
canonical = assessment["canonical"]
|
||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||
system = await systems_svc.create_system(
|
||||
uid, project_id=project_id, name=name,
|
||||
description=description or None, color=color or None,
|
||||
canonical_id=applied,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
||||
return system.to_dict()
|
||||
out = system.to_dict()
|
||||
if applied:
|
||||
out["canonical_note"] = (
|
||||
f"Mapped to the global area '{canonical['name']}' — the same "
|
||||
"spelling-insensitive name. Your System keeps the name you gave it."
|
||||
)
|
||||
elif canonical:
|
||||
out["canonical_suggestion"] = {
|
||||
**canonical,
|
||||
"message": (
|
||||
f"The global catalog has '{canonical['name']}', which may be "
|
||||
f"this same area. If it is, map_system_to_canonical("
|
||||
f"{system.id}, {canonical['id']}) so records and rules about "
|
||||
"this area line up across projects. If this area is specific "
|
||||
"to this project, ignore it — unmapped is a valid state."
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
|
||||
@@ -322,6 +355,64 @@ async def delete_system(system_id: int) -> dict:
|
||||
return {"message": f"System {system_id} deleted."}
|
||||
|
||||
|
||||
async def list_canonical_systems() -> dict:
|
||||
"""The GLOBAL vocabulary of area names, shared by every project.
|
||||
|
||||
These are the standard names to prefer when creating a System, so the same
|
||||
word means the same thing in every project on the instance — and, from
|
||||
milestone 307, the ids a cross-project record can point at. A project's own
|
||||
System keeps whatever name the project calls the area; mapping it here is
|
||||
an association, never a rename.
|
||||
|
||||
Reach for it before create_system when the area is an ordinary one (CI,
|
||||
auth, storage, the API, the UI), and pass the matching `canonical_id`.
|
||||
"""
|
||||
entries = await canonical_systems_svc.list_canonical_systems()
|
||||
return {"canonical_systems": [e.to_dict() for e in entries]}
|
||||
|
||||
|
||||
async def propose_canonical_mappings(project_id: int) -> dict:
|
||||
"""Suggest a global area for each of this project's UNMAPPED Systems.
|
||||
|
||||
Returns PROPOSALS ONLY — nothing is written. Confirm the ones that are
|
||||
right with map_system_to_canonical(system_id, canonical_id); ignore the
|
||||
rest. Each carries a `basis`:
|
||||
|
||||
- `exact` — the names reduce to the same match key ("CI and Release" vs
|
||||
"CI & Release"). Safe to confirm without much thought.
|
||||
- `overlap` — they share a meaningful word ("CI & runners" vs "CI &
|
||||
Release"). A judgment call: confirm only if they really are the same
|
||||
area, since a wrong mapping surfaces cross-project records in the wrong
|
||||
place.
|
||||
|
||||
A System with no proposal is not a problem — unmapped is a valid resting
|
||||
state, and a genuinely project-specific area should stay that way.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return {"proposals": await canonical_systems_svc.propose_mappings(uid, project_id)}
|
||||
|
||||
|
||||
async def map_system_to_canonical(system_id: int, canonical_id: int = 0) -> dict:
|
||||
"""Map one of a project's Systems onto a global area (or clear it).
|
||||
|
||||
Sets `canonical_id` and NOTHING else — the System's name, charter and every
|
||||
record tagged to it are untouched. Pass canonical_id=0 to unmap.
|
||||
|
||||
Args:
|
||||
canonical_id: id from list_canonical_systems; 0 clears the mapping.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await canonical_systems_svc.set_system_canonical(
|
||||
uid, system_id, canonical_id or None,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(
|
||||
f"system {system_id} not found, no write access, "
|
||||
f"or canonical_id {canonical_id} is not a live catalog entry"
|
||||
)
|
||||
return system.to_dict()
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
create_system,
|
||||
@@ -330,5 +421,8 @@ def register(mcp) -> None:
|
||||
update_system,
|
||||
list_system_records,
|
||||
delete_system,
|
||||
list_canonical_systems,
|
||||
propose_canonical_mappings,
|
||||
map_system_to_canonical,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -25,7 +25,7 @@ from scribe.models.user import User # noqa: E402, F401
|
||||
from scribe.models.app_log import AppLog # noqa: E402, F401
|
||||
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
|
||||
from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
||||
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
|
||||
from scribe.models.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401
|
||||
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
||||
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
||||
from scribe.models.project import Project # noqa: E402, F401
|
||||
@@ -39,8 +39,11 @@ from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||
from scribe.models.notification import Notification # noqa: E402, F401
|
||||
from scribe.models.api_key import ApiKey # noqa: E402, F401
|
||||
from scribe.models.user_profile import UserProfile # noqa: E402, F401
|
||||
# Imported before rulebook: rule_systems foreign-keys canonical_systems.
|
||||
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
|
||||
from scribe.models.rulebook import ( # noqa: E402, F401
|
||||
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
|
||||
Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions,
|
||||
rule_systems,
|
||||
)
|
||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from sqlalchemy import Index, Integer, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
||||
|
||||
|
||||
class CanonicalSystem(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""A GLOBAL area name — the shared vocabulary every project's Systems
|
||||
can point at (milestone 307, decision note 3026).
|
||||
|
||||
A `System` is per-project and NOT NULL on project_id, so nothing outside a
|
||||
project can reference one: a rule that applies across projects has no way
|
||||
to say "this is about CI" without chaining itself to one project's row.
|
||||
This table is that join key. It carries no `user_id` on purpose — a shared
|
||||
project must INHERIT the vocabulary rather than re-earn it, so the catalog
|
||||
is global and the same word means the same thing in every install.
|
||||
|
||||
It is a convergence aid, never a gate: `systems.canonical_id` is nullable,
|
||||
an unmapped System stays fully usable, and `record_systems` never sees this
|
||||
table at all — the local name is a legitimate local label and is never
|
||||
rewritten to match.
|
||||
|
||||
`slug` is the match key, not a display value. It folds the spelling
|
||||
differences that produced four names for one area on the author's own
|
||||
instance ("CI & Release" / "CI and Release" / "CI & release"): an exact slug
|
||||
hit maps automatically, and anything short of that becomes a proposal for a
|
||||
human to confirm. See services/canonical_systems.canonical_slug.
|
||||
"""
|
||||
|
||||
__tablename__ = "canonical_systems"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Normalized match key — unique among LIVE rows, so a soft-deleted entry
|
||||
# doesn't block recreating the same area (the partial-unique convention
|
||||
# rules/topics already use).
|
||||
slug: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_canonical_systems_slug", "slug",
|
||||
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"description": self.description,
|
||||
"order_index": self.order_index,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
@@ -45,3 +45,49 @@ class NoteEmbedding(Base):
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class RuleEmbedding(Base):
|
||||
"""One embedding vector per CHUNK of a rule (milestone 307, note 3026).
|
||||
|
||||
A SIBLING of NoteEmbedding rather than a generalisation of it, decided
|
||||
deliberately:
|
||||
|
||||
- The embedding ROW could have been made polymorphic. The SEARCH could not.
|
||||
`semantic_search_notes` is a long function of Note-specific scoping —
|
||||
the visibility clause, the supersession penalty, note_type/task_kind and
|
||||
system filters — and a rule shares none of it. Rules scope by rulebook
|
||||
ownership and project applicability instead.
|
||||
- Generalising the row while still needing two searches is the worst of
|
||||
both: a polymorphic key with referential integrity to neither table, on
|
||||
the path every session start runs, to share four columns.
|
||||
- What is genuinely common is BEHAVIOUR, not storage — get_embedding,
|
||||
chunk_document, embedding_text and CHUNKER_VERSION are already free
|
||||
functions and are reused as-is. Sharing those is the DRY win; sharing
|
||||
the table would have been the DRY costume.
|
||||
|
||||
No `user_id`: NoteEmbedding carries one and its own search deliberately
|
||||
ignores it (scoping on the note instead, or shared records become
|
||||
unreachable). Rather than repeat a column that exists to be ignored, a
|
||||
rule's reach is resolved by joining the rule.
|
||||
"""
|
||||
|
||||
__tablename__ = "rule_embeddings"
|
||||
|
||||
rule_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("rules.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
chunk_index: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
|
||||
# Exactly what this vector encodes — inspectable when a ranking surprises.
|
||||
# For a rule this is the trigger-first document, NOT the rule's `why`:
|
||||
# `why` is dated incident narrative and would drag every rule toward one
|
||||
# centroid (measured in note 2485).
|
||||
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table,
|
||||
Text, UniqueConstraint, text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
||||
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
|
||||
|
||||
|
||||
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
||||
@@ -90,8 +93,26 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
statement: Mapped[str] = mapped_column(Text)
|
||||
# WHEN this rule applies — the trigger, not the instruction. Required of
|
||||
# new rules at the service layer and nullable here, because rules written
|
||||
# before migration 0088 have none and a migration cannot invent one.
|
||||
# It carries three jobs at once (note 3026): it is the tier test made
|
||||
# concrete, the readable form of the canon tag, and the half of the
|
||||
# document that makes a rule findable by meaning.
|
||||
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# always_on = preloaded into every session, as every rule is today.
|
||||
# conditional = reachable, and surfaced when its trigger fires. The
|
||||
# default preserves existing behaviour exactly: nothing stops binding
|
||||
# because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
|
||||
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
|
||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The record that caused this rule — the edge notes and tasks already
|
||||
# have. Rule 46's `why` names note 2813 in prose; this is that link as a
|
||||
# field, so it survives a rewording of the paragraph.
|
||||
arose_from_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -101,14 +122,78 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"statement": self.statement,
|
||||
"when_to_apply": self.when_to_apply or "",
|
||||
"tier": self.tier,
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"arose_from_id": self.arose_from_id,
|
||||
"order_index": self.order_index,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
# Which global AREA a rule is about (milestone 307). Points at the canonical
|
||||
# catalog, NEVER at a project's `systems` row: a rule that spans projects
|
||||
# cannot be chained to one project's vocabulary. This is the edge three
|
||||
# projects were drawing by hand, as rule text copied into a System's charter.
|
||||
rule_systems = Table(
|
||||
"rule_systems",
|
||||
Base.metadata,
|
||||
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("canonical_id", Integer, ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
|
||||
class RuleRelation(Base, CreatedAtMixin):
|
||||
"""A typed edge between two rules. Each kind exists because its ABSENCE
|
||||
forced a workaround somewhere in the operator's rulebook (note 3026).
|
||||
|
||||
- ``co_surfaces`` — these fail together, so they must arrive together.
|
||||
Without it, the only way to guarantee that was to merge them into one
|
||||
row, which is what happened to rule 46: split into 144, folded back the
|
||||
same day because "either rule could surface without the other."
|
||||
Symmetric in meaning; stored once and read both ways.
|
||||
- ``overrides`` — this rule supersedes that one for its scope. Only
|
||||
*suppression* existed, so an override had to be written as a parallel
|
||||
rule that then drifts from its parent.
|
||||
- ``elaborates`` — this rule adds local specifics to that one; surfacing
|
||||
the parent brings the addendum with it.
|
||||
|
||||
``note`` records WHY the edge was drawn, for the same reason a rule
|
||||
carries `why`: a later reader deciding whether it still holds needs the
|
||||
reasoning, not just the fact.
|
||||
"""
|
||||
|
||||
__tablename__ = "rule_relations"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
from_rule_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
to_rule_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# CHECK ck_rule_relations_kind (migration 0088, rule 36).
|
||||
kind: Mapped[str] = mapped_column(Text)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"from_rule_id": self.from_rule_id,
|
||||
"to_rule_id": self.to_rule_id,
|
||||
"kind": self.kind,
|
||||
"note": self.note or "",
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
# Pure many-to-many — no model class, just the join table.
|
||||
project_rulebook_subscriptions = Table(
|
||||
"project_rulebook_subscriptions",
|
||||
|
||||
@@ -24,6 +24,15 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
# The GLOBAL area this local System is an instance of (milestone 307).
|
||||
# Nullable and SET NULL on purpose: the catalog is a convergence aid, not a
|
||||
# gate — an unmapped System is fully usable, and retiring a canonical entry
|
||||
# must never take a project's System with it. The local `name` is NEVER
|
||||
# rewritten to match the canonical one; this column is the join key, and
|
||||
# the name stays whatever the project calls the area.
|
||||
canonical_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("canonical_systems.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# active | archived — systems accumulate; archive rather than delete.
|
||||
@@ -40,6 +49,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"user_id": self.user_id,
|
||||
"project_id": self.project_id,
|
||||
"name": self.name,
|
||||
"canonical_id": self.canonical_id,
|
||||
"description": self.description,
|
||||
"color": self.color,
|
||||
"status": self.status,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Canonical-system routes — the GLOBAL area vocabulary, and the mapping of a
|
||||
project's Systems onto it (milestone 307, decision note 3026).
|
||||
|
||||
Two shapes live here because they are two halves of one idea:
|
||||
|
||||
- `/api/canonical-systems` — the catalog itself. Readable by any signed-in
|
||||
user (it is shared vocabulary, not user data); writable only by an admin,
|
||||
since a global list anyone can extend stops being a shared list.
|
||||
- the mapping endpoints — authorised by the PROJECT, because mapping writes a
|
||||
project's own System row. The service enforces both; these are thin wrappers.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import admin_required, get_current_user_id, login_required
|
||||
from scribe.routes.utils import not_found
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
from scribe.services.projects import get_project_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
canonical_systems_bp = Blueprint("canonical_systems", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/canonical-systems", methods=["GET"])
|
||||
@login_required
|
||||
async def list_canonical_systems_route():
|
||||
entries = await canonical_svc.list_canonical_systems()
|
||||
return jsonify({"canonical_systems": [e.to_dict() for e in entries]})
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/canonical-systems", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_canonical_system_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
if not (data.get("name") or "").strip():
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
entry = await canonical_svc.create_canonical_system(
|
||||
uid, data["name"], description=data.get("description"),
|
||||
)
|
||||
if entry is None:
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
# The slug duplicate gate answers with the entry that already covers the
|
||||
# area rather than minting a second spelling of it — 409, not a silent
|
||||
# second row (the whole point of the table).
|
||||
if isinstance(entry, dict):
|
||||
return jsonify(entry), 409
|
||||
return jsonify(entry.to_dict()), 201
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/canonical-systems/<int:canonical_id>", methods=["PATCH"])
|
||||
@admin_required
|
||||
async def update_canonical_system_route(canonical_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
fields = {k: v for k, v in data.items() if k in ("name", "description", "order_index")}
|
||||
entry = await canonical_svc.update_canonical_system(uid, canonical_id, **fields)
|
||||
if entry is None:
|
||||
return not_found("Canonical system")
|
||||
return jsonify(entry.to_dict())
|
||||
|
||||
|
||||
@canonical_systems_bp.route(
|
||||
"/projects/<int:project_id>/canonical-proposals", methods=["GET"]
|
||||
)
|
||||
@login_required
|
||||
async def propose_canonical_mappings_route(project_id: int):
|
||||
"""Proposals only — this endpoint writes nothing. The PUT below applies one."""
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
return jsonify({"proposals": await canonical_svc.propose_mappings(uid, project_id)})
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/systems/<int:system_id>/canonical", methods=["PUT"])
|
||||
@login_required
|
||||
async def map_system_to_canonical_route(system_id: int):
|
||||
"""Map or unmap one System. Body: {"canonical_id": <id>|null}.
|
||||
|
||||
Sets that column and nothing else — no rename, no change to which records
|
||||
are tagged to the System.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
canonical_id = data.get("canonical_id")
|
||||
if canonical_id is not None and not isinstance(canonical_id, int):
|
||||
return jsonify({"error": "canonical_id must be an integer or null"}), 400
|
||||
system = await canonical_svc.set_system_canonical(uid, system_id, canonical_id)
|
||||
if system is None:
|
||||
return not_found("System or canonical system")
|
||||
return jsonify(system.to_dict())
|
||||
@@ -129,6 +129,11 @@ async def write_path_prior_art():
|
||||
surfaced. A separate channel on purpose: a reuse
|
||||
hint shown early must not suppress the record-sync
|
||||
nudge when the recorded file is edited later.
|
||||
exclude_rule_ids (opt) — comma-separated RULE ids already surfaced
|
||||
this session. Its own channel like the three
|
||||
above, and for the same reason: a rule named
|
||||
twenty turns ago should not be re-offered on
|
||||
every subsequent write.
|
||||
exclude_derive (opt) — comma-separated derive keys (a derive group id
|
||||
or `canon:<snippet_id>`) already named this
|
||||
session by the ledger arm (#2900); its own
|
||||
@@ -151,6 +156,7 @@ async def write_path_prior_art():
|
||||
exclude_derive = [
|
||||
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
|
||||
]
|
||||
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||
@@ -161,6 +167,7 @@ async def write_path_prior_art():
|
||||
stamp_shapes=shapes if may_stamp else None,
|
||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||
exclude_derive=exclude_derive,
|
||||
exclude_rule_ids=exclude_rule_ids,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -162,33 +162,73 @@ async def create_rule(topic_id: int):
|
||||
why=data.get("why", ""),
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
tier=data.get("tier", "always_on"),
|
||||
arose_from_id=data.get("arose_from_id", 0) or 0,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(rule.to_dict()), 201
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
get_current_user_id(), rule, data.get("system_ids"),
|
||||
)), 201
|
||||
|
||||
|
||||
@rulebooks_bp.get("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def get_rule(rule_id: int):
|
||||
rule = await rulebooks_svc.get_rule(rule_id, get_current_user_id())
|
||||
uid = get_current_user_id()
|
||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(rule.to_dict())
|
||||
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
|
||||
|
||||
|
||||
@rulebooks_bp.patch("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def update_rule(rule_id: int):
|
||||
data = await request.get_json() or {}
|
||||
uid = get_current_user_id()
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("title", "statement", "why", "how_to_apply", "order_index")
|
||||
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "arose_from_id")
|
||||
}
|
||||
rule = await rulebooks_svc.update_rule(rule_id, get_current_user_id(), **fields)
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(rule.to_dict())
|
||||
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
|
||||
@login_required
|
||||
async def relate_rules(rule_id: int):
|
||||
"""Draw a typed edge FROM this rule to another.
|
||||
|
||||
Body: {"to_rule_id": N, "kind": "co_surfaces"|"overrides"|"elaborates",
|
||||
"note": "..."}. Idempotent — re-drawing an edge returns the existing one.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
to_rule_id = data.get("to_rule_id")
|
||||
if not isinstance(to_rule_id, int):
|
||||
return jsonify({"error": "to_rule_id is required"}), 400
|
||||
try:
|
||||
relation = await rulebooks_svc.add_rule_relation(
|
||||
get_current_user_id(), rule_id, to_rule_id,
|
||||
data.get("kind", ""), data.get("note", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if relation is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(relation.to_dict()), 201
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/rule-relations/<int:relation_id>")
|
||||
@login_required
|
||||
async def unrelate_rules(relation_id: int):
|
||||
if not await rulebooks_svc.remove_rule_relation(get_current_user_id(), relation_id):
|
||||
return jsonify({"error": "relation not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
||||
@@ -332,7 +372,12 @@ async def create_project_rule(project_id: int):
|
||||
why=data.get("why", ""),
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
tier=data.get("tier", "always_on"),
|
||||
arose_from_id=data.get("arose_from_id", 0) or 0,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(rule.to_dict()), 201
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
get_current_user_id(), rule, data.get("system_ids"),
|
||||
)), 201
|
||||
|
||||
@@ -62,14 +62,38 @@ async def create_system_route(project_id: int):
|
||||
data = await request.get_json() or {}
|
||||
if not (data.get("name") or "").strip():
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
# The same gate the MCP door enforces. It lived only in the tool layer
|
||||
# until now, which is exactly how the web UI shipped without gates the
|
||||
# agent surface had (#2482) — one service call, one answer (rule 33).
|
||||
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
|
||||
duplicate = assessment["duplicate"]
|
||||
if duplicate and not data.get("force"):
|
||||
return jsonify({
|
||||
"duplicate": True,
|
||||
"existing_id": duplicate["id"],
|
||||
"error": (
|
||||
f"“{duplicate['name']}” already covers this area in this "
|
||||
"project. Tag records to it, or rename it if its charter has "
|
||||
"moved on — a second System with the same name splits the "
|
||||
"area's records across two piles."
|
||||
),
|
||||
}), 409
|
||||
canonical = assessment["canonical"]
|
||||
# Exact is mechanical and applied; overlap is a judgment call and is only
|
||||
# offered back for the form to present.
|
||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||
system = await systems_svc.create_system(
|
||||
uid, project_id=project_id, name=data["name"],
|
||||
description=data.get("description"), color=data.get("color"),
|
||||
order_index=data.get("order_index", 0),
|
||||
canonical_id=data.get("canonical_id") or applied,
|
||||
)
|
||||
if system is None:
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
return jsonify(system.to_dict()), 201
|
||||
out = system.to_dict()
|
||||
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
|
||||
out["canonical_suggestion"] = canonical
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
|
||||
|
||||
@@ -11,6 +11,8 @@ from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
@@ -69,11 +71,18 @@ _BACKED_UP = [
|
||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
|
||||
"code_shapes", "code_shape_events", "code_shape_uses",
|
||||
# v9 (2026-08): the global area catalog (milestone 307). Global, not
|
||||
# user-scoped, so it rides in EVERY export — including a single-user
|
||||
# one, whose Systems would otherwise restore unmapped.
|
||||
"canonical_systems",
|
||||
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
|
||||
"rule_systems", "rule_relations",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
|
||||
# note_embeddings are derived (regenerated from note bodies); api_keys are
|
||||
# note_embeddings and rule_embeddings are derived (regenerated at startup
|
||||
# from the records themselves, which is also how a chunker bump is handled); api_keys are
|
||||
# sensitive credentials; retrieval_logs is observational telemetry that nothing
|
||||
# reads for correctness and that grows per query; the rest are
|
||||
# transient/operational.
|
||||
@@ -83,7 +92,7 @@ _BACKED_UP = [
|
||||
# like coverage while naming nothing the schema could confirm.
|
||||
_NOT_INCLUDED = [
|
||||
"groups", "group_memberships", "project_shares", "note_shares",
|
||||
"api_keys", "note_embeddings", "app_logs", "notifications",
|
||||
"api_keys", "note_embeddings", "rule_embeddings", "app_logs", "notifications",
|
||||
"invitation_tokens", "password_reset_tokens", "user_profiles",
|
||||
"retrieval_logs",
|
||||
# Sensitive credentials, same reasoning as api_keys: a backup that carries
|
||||
@@ -127,12 +136,29 @@ def _rulebook_exclusion_rows(rows) -> list[dict]:
|
||||
# same reason: CI has no database, so a serialiser that is a plain function is
|
||||
# one that can actually be tested.
|
||||
|
||||
def _system_rows(rows) -> list[dict]:
|
||||
def _canonical_system_rows(rows) -> list[dict]:
|
||||
"""The global area catalog. Carried WITHOUT ids: a restore matches on slug,
|
||||
so a target install that already seeded the standard vocabulary reuses its
|
||||
own rows and only gains the entries an admin added here."""
|
||||
return [
|
||||
{
|
||||
"name": r.name, "slug": r.slug, "description": r.description,
|
||||
"order_index": r.order_index,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _system_rows(rows, canonical_slugs: dict[int, str]) -> list[dict]:
|
||||
"""A project's Systems. The canonical mapping travels as a SLUG, not an id
|
||||
— the catalog is global and its ids are per-install, so an id would restore
|
||||
pointing at whatever area happened to land on that number."""
|
||||
return [
|
||||
{
|
||||
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
||||
"name": r.name, "description": r.description, "color": r.color,
|
||||
"status": r.status, "order_index": r.order_index,
|
||||
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -332,12 +358,34 @@ def _topic_rows(rows) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _rule_system_rows(rows) -> list[dict]:
|
||||
"""A rule's area tags, carried by canonical SLUG for the same reason the
|
||||
Systems are: the catalog is global and its ids are per-install."""
|
||||
return [{"rule_id": rule_id, "canonical_slug": slug} for rule_id, slug in rows]
|
||||
|
||||
|
||||
def _rule_relation_rows(rows) -> list[dict]:
|
||||
"""The typed edges between rules. Carried because they are a JUDGEMENT —
|
||||
someone decided these two fail together, or that one supersedes the other,
|
||||
and nothing in either rule's text records the decision. Lose them and a
|
||||
split rule silently starts arriving half at a time again."""
|
||||
return [
|
||||
{
|
||||
"from_rule_id": r.from_rule_id, "to_rule_id": r.to_rule_id,
|
||||
"kind": r.kind, "note": r.note,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _rule_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
||||
"title": r.title, "statement": r.statement, "why": r.why,
|
||||
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
||||
"when_to_apply": r.when_to_apply, "tier": r.tier,
|
||||
"arose_from_id": r.arose_from_id,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
"updated_at": r.updated_at.isoformat(),
|
||||
}
|
||||
@@ -363,6 +411,15 @@ async def export_full_backup() -> dict:
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
systems = (await session.execute(select(System))).scalars().all()
|
||||
canonical_systems = (await session.execute(
|
||||
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
||||
.order_by(CanonicalSystem.order_index)
|
||||
)).scalars().all()
|
||||
rule_system_rows = (await session.execute(
|
||||
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
|
||||
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||
)).all()
|
||||
rule_relations = (await session.execute(select(RuleRelation))).scalars().all()
|
||||
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
||||
supersessions = (
|
||||
await session.execute(select(NoteSupersession))
|
||||
@@ -424,7 +481,12 @@ async def export_full_backup() -> dict:
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||
"systems": _system_rows(systems),
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"rule_relations": _rule_relation_rows(rule_relations),
|
||||
"systems": _system_rows(
|
||||
systems, {c.id: c.slug for c in canonical_systems}
|
||||
),
|
||||
"record_systems": _record_system_rows(record_systems),
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
@@ -467,6 +529,12 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
systems = (await session.execute(
|
||||
select(System).where(System.user_id == user_id)
|
||||
)).scalars().all()
|
||||
# Global: taken whole even in a per-user export, because the Systems
|
||||
# above reference it and a partial catalog restores partial mappings.
|
||||
canonical_systems = (await session.execute(
|
||||
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
||||
.order_by(CanonicalSystem.order_index)
|
||||
)).scalars().all()
|
||||
system_ids = [sy.id for sy in systems]
|
||||
note_ids = [n.id for n in notes]
|
||||
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
|
||||
@@ -533,6 +601,20 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
rules = (await session.execute(
|
||||
select(Rule).where(or_(*rule_filters))
|
||||
)).scalars().all() if rule_filters else []
|
||||
# Scoped to the rules this export already carries: an edge whose far
|
||||
# end is absent would restore pointing at nothing.
|
||||
_rule_ids = [r.id for r in rules]
|
||||
rule_system_rows = (await session.execute(
|
||||
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
|
||||
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
|
||||
)).all() if _rule_ids else []
|
||||
rule_relations = (await session.execute(
|
||||
select(RuleRelation).where(
|
||||
RuleRelation.from_rule_id.in_(_rule_ids),
|
||||
RuleRelation.to_rule_id.in_(_rule_ids),
|
||||
)
|
||||
)).scalars().all() if _rule_ids else []
|
||||
if project_ids:
|
||||
subscriptions = (await session.execute(
|
||||
select(project_rulebook_subscriptions).where(
|
||||
@@ -583,7 +665,12 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||
"systems": _system_rows(systems),
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"rule_relations": _rule_relation_rows(rule_relations),
|
||||
"systems": _system_rows(
|
||||
systems, {c.id: c.slug for c in canonical_systems}
|
||||
),
|
||||
"record_systems": _record_system_rows(record_systems),
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
@@ -697,7 +784,8 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
"code_shape_uses": 0,
|
||||
"code_shape_uses": 0, "canonical_systems": 0,
|
||||
"rule_systems": 0, "rule_relations": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -914,6 +1002,11 @@ async def _restore_v2(data: dict) -> dict:
|
||||
statement=r_data.get("statement", ""),
|
||||
why=r_data.get("why") or None,
|
||||
how_to_apply=r_data.get("how_to_apply") or None,
|
||||
when_to_apply=r_data.get("when_to_apply") or None,
|
||||
# A file written before migration 0088 has no tier. always_on
|
||||
# is the pre-0088 behaviour, so an old backup restores rules
|
||||
# that bind exactly as they did when it was taken.
|
||||
tier=r_data.get("tier") or "always_on",
|
||||
order_index=r_data.get("order_index", 0),
|
||||
created_at=_dt(r_data.get("created_at")),
|
||||
updated_at=_dt(r_data.get("updated_at")),
|
||||
@@ -970,8 +1063,58 @@ async def _restore_v2(data: dict) -> dict:
|
||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||
# payload restores without them rather than failing on an absent key.
|
||||
|
||||
# 15. Systems
|
||||
system_id_map: dict[int, int] = {}
|
||||
|
||||
# 14c. The global area catalog, matched on SLUG. This install already
|
||||
# has the standard vocabulary from its migrations, so the common case
|
||||
# adds nothing and simply learns which local id each slug is; only an
|
||||
# entry an admin added on the source instance is created here. Runs
|
||||
# BEFORE systems, which resolve their mapping through this map.
|
||||
canonical_id_by_slug: dict[str, int] = {}
|
||||
existing_canonical = (await session.execute(
|
||||
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
for entry in existing_canonical:
|
||||
canonical_id_by_slug[entry.slug] = entry.id
|
||||
for cs_data in data.get("canonical_systems", []):
|
||||
slug = cs_data.get("slug") or ""
|
||||
if not slug or slug in canonical_id_by_slug:
|
||||
continue
|
||||
entry = CanonicalSystem(
|
||||
name=cs_data.get("name", ""), slug=slug,
|
||||
description=cs_data.get("description"),
|
||||
order_index=cs_data.get("order_index", 0),
|
||||
)
|
||||
session.add(entry)
|
||||
await session.flush()
|
||||
canonical_id_by_slug[slug] = entry.id
|
||||
stats["canonical_systems"] += 1
|
||||
|
||||
# 14d. A rule's area tags and its typed edges. Runs HERE, not beside the
|
||||
# rules in section 11, because it needs both maps: the rule ids from
|
||||
# there and the canonical slugs from 14c just above.
|
||||
for rs in data.get("rule_systems", []):
|
||||
mapped_rule = rule_id_map.get(rs.get("rule_id", 0))
|
||||
canonical_id = canonical_id_by_slug.get(rs.get("canonical_slug") or "")
|
||||
if mapped_rule is None or canonical_id is None:
|
||||
continue
|
||||
await session.execute(rule_systems_t.insert().values(
|
||||
rule_id=mapped_rule, canonical_id=canonical_id,
|
||||
))
|
||||
stats["rule_systems"] += 1
|
||||
|
||||
for rr in data.get("rule_relations", []):
|
||||
mapped_from = rule_id_map.get(rr.get("from_rule_id", 0))
|
||||
mapped_to = rule_id_map.get(rr.get("to_rule_id", 0))
|
||||
if mapped_from is None or mapped_to is None or mapped_from == mapped_to:
|
||||
continue
|
||||
session.add(RuleRelation(
|
||||
from_rule_id=mapped_from, to_rule_id=mapped_to,
|
||||
kind=rr.get("kind", "co_surfaces"), note=rr.get("note") or None,
|
||||
))
|
||||
stats["rule_relations"] += 1
|
||||
|
||||
# 15. Systems
|
||||
for sy_data in data.get("systems", []):
|
||||
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
|
||||
@@ -984,6 +1127,9 @@ async def _restore_v2(data: dict) -> dict:
|
||||
color=sy_data.get("color"),
|
||||
status=sy_data.get("status", "active"),
|
||||
order_index=sy_data.get("order_index", 0),
|
||||
# An unknown slug restores UNMAPPED rather than failing: the
|
||||
# System and its records are the payload, the mapping is an aid.
|
||||
canonical_id=canonical_id_by_slug.get(sy_data.get("canonical_slug") or ""),
|
||||
)
|
||||
session.add(system)
|
||||
await session.flush()
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""The global canonical area vocabulary, and the mapping from a project's
|
||||
Systems onto it (milestone 307 step 1, decision note 3026).
|
||||
|
||||
A `System` is per-project. Nothing outside a project can reference one, so a
|
||||
rule that spans projects has no way to say "this is about CI" without chaining
|
||||
itself to one project's row. `CanonicalSystem` is that join key, and it is
|
||||
GLOBAL — no `user_id`, so a shared project inherits the vocabulary instead of
|
||||
re-earning it.
|
||||
|
||||
Two rules govern everything here:
|
||||
|
||||
- **Associate, never rewrite.** Mapping a System sets `systems.canonical_id`
|
||||
and nothing else. The local name stays whatever the project calls the area,
|
||||
and `record_systems` is never touched — no record's tags move.
|
||||
- **Propose, never decide.** An exact slug hit is mechanical and maps on
|
||||
request; anything short of that is a PROPOSAL a human confirms. "CI &
|
||||
Release" vs "CI & runners" is a judgment call, and the cost of guessing it
|
||||
wrong silently is a rule surfacing in the wrong project.
|
||||
|
||||
Reads are open to any authenticated caller (the catalog is shared vocabulary,
|
||||
not user data). Writes to the catalog itself are admin-only: a global table
|
||||
that anyone can extend is how a shared vocabulary stops being shared.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.system import System
|
||||
from scribe.models.user import User
|
||||
from scribe.services import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tokens that carry no meaning for matching — "&" becomes "and" before the
|
||||
# split, so it would otherwise dominate the overlap score of every pair.
|
||||
_NOISE_TOKENS = frozenset({"and", "the", "a", "of"})
|
||||
|
||||
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def canonical_slug(name: str) -> str:
|
||||
"""The match key for an area name — NOT a display value.
|
||||
|
||||
Folds exactly the spelling differences that produced three names for one
|
||||
area on the author's instance: `CI & Release`, `CI and Release` and
|
||||
`CI & release` all slug to `ci-and-release`, so they map mechanically.
|
||||
A real difference survives: `CI & runners` slugs to `ci-and-runners` and
|
||||
goes through the proposal path where a human decides.
|
||||
"""
|
||||
lowered = name.strip().lower().replace("&", " and ")
|
||||
return "-".join(_NON_ALNUM.sub(" ", lowered).split())
|
||||
|
||||
|
||||
def _tokens(slug: str) -> frozenset[str]:
|
||||
return frozenset(slug.split("-")) - _NOISE_TOKENS
|
||||
|
||||
|
||||
async def _is_admin(user_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
role = await session.scalar(select(User.role).where(User.id == user_id))
|
||||
return role == "admin"
|
||||
|
||||
|
||||
async def list_canonical_systems() -> list[CanonicalSystem]:
|
||||
"""The whole catalog, in display order. Global — no ownership filter."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(CanonicalSystem)
|
||||
.where(CanonicalSystem.deleted_at.is_(None))
|
||||
.order_by(CanonicalSystem.order_index.asc(), CanonicalSystem.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_canonical_system(canonical_id: int) -> CanonicalSystem | None:
|
||||
async with async_session() as session:
|
||||
entry = await session.get(CanonicalSystem, canonical_id)
|
||||
return entry if entry is not None and entry.deleted_at is None else None
|
||||
|
||||
|
||||
async def find_by_name(name: str) -> CanonicalSystem | None:
|
||||
"""The exact-slug lookup — the mechanical half of matching."""
|
||||
slug = canonical_slug(name)
|
||||
if not slug:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
return await session.scalar(
|
||||
select(CanonicalSystem).where(
|
||||
CanonicalSystem.slug == slug,
|
||||
CanonicalSystem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
|
||||
return len(local & other) / max(len(local | other), 1)
|
||||
|
||||
|
||||
async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
|
||||
"""The closest catalog entry that shares a meaningful word, or None.
|
||||
|
||||
The ONE scorer behind both offers: the create-time suggestion and the
|
||||
review surface. Two scorers would eventually disagree about which area a
|
||||
name resembles, and the operator would be asked one question at create
|
||||
time and a different one at review.
|
||||
|
||||
The threshold is any shared meaningful word, deliberately generous: a
|
||||
wrong offer costs one dismissal, a missing one costs a mapping nobody
|
||||
thinks to make again. Nothing here ever applies — `overlap` is always an
|
||||
offer (see propose_mappings).
|
||||
"""
|
||||
slug = canonical_slug(name)
|
||||
if not slug:
|
||||
return None
|
||||
local = _tokens(slug)
|
||||
if not local:
|
||||
return None
|
||||
# A caller already holding the catalog passes it: this runs once per
|
||||
# unmapped System in the review sweep, and re-reading the table each time
|
||||
# would make an N+1 out of a report.
|
||||
if catalog is None:
|
||||
catalog = await list_canonical_systems()
|
||||
best, best_score = None, 0.0
|
||||
for entry in catalog:
|
||||
score = _overlap(local, _tokens(entry.slug))
|
||||
if score > best_score:
|
||||
best, best_score = entry, score
|
||||
if best is None or best_score <= 0:
|
||||
return None
|
||||
return {
|
||||
"id": best.id, "name": best.name,
|
||||
"basis": "overlap", "score": round(best_score, 3),
|
||||
}
|
||||
|
||||
|
||||
async def create_canonical_system(
|
||||
user_id: int, name: str, description: str | None = None,
|
||||
) -> CanonicalSystem | dict | None:
|
||||
"""Add an area to the global catalog. Admin only.
|
||||
|
||||
Duplicate-gated on the SLUG, not the raw name, so "CI and Release" cannot
|
||||
be added alongside "CI & Release" — that is the drift this table exists to
|
||||
end. Returns the existing entry's id instead of creating a second one.
|
||||
"""
|
||||
if not await _is_admin(user_id):
|
||||
return None
|
||||
slug = canonical_slug(name)
|
||||
if not slug:
|
||||
return None
|
||||
existing = await find_by_name(name)
|
||||
if existing is not None:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": existing.id,
|
||||
"message": (
|
||||
f"'{existing.name}' (#{existing.id}) already covers this area — "
|
||||
f"both names reduce to '{slug}'. Map Systems to it, or "
|
||||
"update_canonical_system if the charter needs revising."
|
||||
),
|
||||
}
|
||||
async with async_session() as session:
|
||||
highest = await session.scalar(
|
||||
select(CanonicalSystem.order_index)
|
||||
.order_by(CanonicalSystem.order_index.desc())
|
||||
.limit(1)
|
||||
)
|
||||
entry = CanonicalSystem(
|
||||
name=" ".join(name.split()),
|
||||
slug=slug,
|
||||
description=description,
|
||||
order_index=(highest or 0) + 1,
|
||||
)
|
||||
session.add(entry)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def update_canonical_system(
|
||||
user_id: int, canonical_id: int, **fields: object,
|
||||
) -> CanonicalSystem | None:
|
||||
"""Rename or re-charter a catalog entry. Admin only.
|
||||
|
||||
A rename recomputes the slug — the display name and the match key must not
|
||||
be allowed to disagree, or the exact-match path silently stops finding it.
|
||||
"""
|
||||
if not await _is_admin(user_id):
|
||||
return None
|
||||
allowed = {"name", "description", "order_index"}
|
||||
async with async_session() as session:
|
||||
entry = await session.get(CanonicalSystem, canonical_id)
|
||||
if entry is None or entry.deleted_at is not None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(entry, key, value)
|
||||
if "name" in fields and fields["name"]:
|
||||
entry.name = " ".join(str(fields["name"]).split())
|
||||
entry.slug = canonical_slug(entry.name)
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def set_system_canonical(
|
||||
user_id: int, system_id: int, canonical_id: int | None,
|
||||
) -> System | None:
|
||||
"""Map (or unmap) one project System onto a catalog entry.
|
||||
|
||||
Authorised by the PROJECT, not the catalog: mapping changes the project's
|
||||
row, so project write access is the right gate (rule 78 — never a bare
|
||||
owner filter). Passing None clears the mapping.
|
||||
|
||||
Touches `canonical_id` and nothing else — the System's own name, charter
|
||||
and record associations are left exactly as they are.
|
||||
"""
|
||||
if canonical_id is not None and await get_canonical_system(canonical_id) is None:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = await session.get(System, system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
if not await access.can_write_project(user_id, system.project_id):
|
||||
return None
|
||||
system.canonical_id = canonical_id
|
||||
system.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
|
||||
"""Suggest a catalog entry for each of a project's UNMAPPED Systems.
|
||||
|
||||
Returns proposals, never applied changes — `set_system_canonical` is the
|
||||
only thing that writes. Each carries a `basis` so the reviewer knows what
|
||||
they are approving:
|
||||
|
||||
- `exact` — the two names reduce to the same slug. Mechanical.
|
||||
- `overlap` — they share a meaningful word ("CI & runners" / "CI &
|
||||
Release"). A judgment call, and the reason this is a proposal at all.
|
||||
|
||||
A System with no plausible match simply gets no proposal: unmapped is a
|
||||
perfectly good resting state, so silence here is an answer, not a gap.
|
||||
"""
|
||||
if not await access.can_read_project(user_id, project_id):
|
||||
return []
|
||||
catalog = await list_canonical_systems()
|
||||
if not catalog:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(System).where(
|
||||
System.project_id == project_id,
|
||||
System.canonical_id.is_(None),
|
||||
System.deleted_at.is_(None),
|
||||
).order_by(System.order_index.asc(), System.created_at.asc())
|
||||
)
|
||||
systems = list(result.scalars().all())
|
||||
|
||||
by_slug = {entry.slug: entry for entry in catalog}
|
||||
proposals: list[dict] = []
|
||||
for system in systems:
|
||||
slug = canonical_slug(system.name)
|
||||
if not slug:
|
||||
continue
|
||||
exact = by_slug.get(slug)
|
||||
if exact is not None:
|
||||
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
|
||||
else:
|
||||
# Same scorer the create-time offer uses, so the two surfaces can
|
||||
# never name different areas for one System.
|
||||
match = await best_overlap(system.name, catalog)
|
||||
if match is None:
|
||||
continue
|
||||
proposals.append({
|
||||
"system_id": system.id,
|
||||
"system_name": system.name,
|
||||
"canonical_id": match["id"],
|
||||
"canonical_name": match["name"],
|
||||
"basis": match["basis"],
|
||||
"score": match["score"],
|
||||
})
|
||||
proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
|
||||
return proposals
|
||||
@@ -16,13 +16,18 @@ import os
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.services.access import notes_visibility_clause
|
||||
|
||||
if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime
|
||||
from scribe.models.rulebook import Rule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum cosine similarity to include a note in context results.
|
||||
@@ -612,3 +617,193 @@ async def backfill_note_embeddings() -> None:
|
||||
await asyncio.sleep(0.05) # gentle pacing
|
||||
|
||||
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
|
||||
|
||||
|
||||
# ── Rules (milestone 307, note 3026) ────────────────────────────────────
|
||||
|
||||
def rule_document(
|
||||
title: str | None, statement: str | None, when_to_apply: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""The (title, body) a rule is EMBEDDED as — trigger first, `why` never.
|
||||
|
||||
Both halves of this are measured, not guessed (note 2485). That pass found
|
||||
the snippet was the only sharp record in the corpus — a 0.153 top-to-second
|
||||
gap against 0.010–0.023 for everything else — and that the cause was its
|
||||
SHAPE: `{name} — {when_to_use}` as the title and `**When to use:** …`
|
||||
repeated in the body, so purpose appears twice in a short document and
|
||||
dominates the vector. This mirrors that exactly.
|
||||
|
||||
And it excludes `why` on the same evidence. `why` is dated incident
|
||||
narrative — rule 46's runs to 4,300 characters of it — and long,
|
||||
multi-topic prose is precisely what made sixteen dev-logs mutually
|
||||
indistinguishable: the average lands on the centroid of "development",
|
||||
which every one of them shares. Adding `why` would not give the vector more
|
||||
to work with; it would give every rule the same thing to work with.
|
||||
|
||||
A rule with no trigger yet degrades to title + statement. It still embeds,
|
||||
just less sharply — which is an argument for backfilling triggers, not an
|
||||
argument for padding the document with whatever text is lying around.
|
||||
"""
|
||||
trigger = (when_to_apply or "").strip()
|
||||
name = (title or "").strip()
|
||||
body = (statement or "").strip()
|
||||
if not trigger:
|
||||
return name or None, body or None
|
||||
return (
|
||||
f"{name} — {trigger}" if name else trigger,
|
||||
f"When to apply: {trigger}\n\n{body}" if body else f"When to apply: {trigger}",
|
||||
)
|
||||
|
||||
|
||||
async def upsert_rule_embedding(
|
||||
rule_id: int, title: str | None, statement: str | None,
|
||||
when_to_apply: str | None = None,
|
||||
) -> None:
|
||||
"""Chunk, embed and persist a rule's vectors. Safe to fire-and-forget.
|
||||
|
||||
The note twin's contract, for the same reasons: the document is built HERE
|
||||
so the write path, the backfill and any re-embed share one definition, and
|
||||
replacement is atomic per rule so a concurrent read sees the old chunk set
|
||||
or the new one, never a mixture.
|
||||
"""
|
||||
doc_title, doc_body = rule_document(title, statement, when_to_apply)
|
||||
chunks = chunk_document(doc_title, doc_body)
|
||||
try:
|
||||
if not chunks:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
||||
)
|
||||
await session.commit()
|
||||
return
|
||||
except Exception:
|
||||
logger.warning("Failed to clear embedding for rule %d", rule_id, exc_info=True)
|
||||
return
|
||||
|
||||
try:
|
||||
vectors = await get_embeddings(chunks)
|
||||
except Exception:
|
||||
logger.debug("Skipping embedding for rule %d — embedder unavailable", rule_id)
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
||||
)
|
||||
for index, (chunk, vector) in enumerate(zip(chunks, vectors)):
|
||||
session.add(
|
||||
RuleEmbedding(
|
||||
rule_id=rule_id,
|
||||
chunk_index=index,
|
||||
embedding=vector,
|
||||
chunk_text=chunk,
|
||||
chunker_version=CHUNKER_VERSION,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.warning("Failed to persist embedding for rule %d", rule_id, exc_info=True)
|
||||
|
||||
|
||||
async def semantic_search_rules(
|
||||
user_id: int,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
tier: str | None = None,
|
||||
) -> list[tuple[float, "Rule"]]:
|
||||
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
|
||||
|
||||
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
|
||||
its project. Deliberately not filtered to what currently BINDS a given
|
||||
project: this answers "is there a rule about this", which a person asking
|
||||
wants answered across their whole rulebook. Deciding which rules bind where
|
||||
is the surfacing question, and it has its own machinery
|
||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||
|
||||
`tier` narrows to one tier. The write-path hint passes "conditional",
|
||||
because an always-on rule is ALREADY in the session — surfacing it again as
|
||||
a suggestion is pure noise, and noise on a hint that fires on every write
|
||||
is how a hint gets ignored.
|
||||
|
||||
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
||||
across chunks competes once rather than crowding the results with itself.
|
||||
|
||||
Returns an empty list if the embedder is unavailable or on any error.
|
||||
"""
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
|
||||
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
try:
|
||||
query_vec = await get_embedding(query)
|
||||
except Exception:
|
||||
logger.debug("Rule search skipped — embedder unavailable")
|
||||
return []
|
||||
|
||||
max_distance = min(2.0, max(0.0, 1.0 - threshold))
|
||||
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Rule, distance.label("distance"))
|
||||
.select_from(RuleEmbedding)
|
||||
.join(Rule, RuleEmbedding.rule_id == Rule.id)
|
||||
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.outerjoin(Project, Rule.project_id == Project.id)
|
||||
.where(
|
||||
Rule.deleted_at.is_(None),
|
||||
distance <= max_distance,
|
||||
# topic_id XOR project_id, so exactly one arm can match.
|
||||
or_(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Project.user_id == user_id,
|
||||
),
|
||||
*( [Rule.tier == tier] if tier else [] ),
|
||||
)
|
||||
# Overfetch so collapsing chunks to their best row still fills
|
||||
# the page — the same reason the note search overfetches.
|
||||
.order_by(distance)
|
||||
.limit(limit * _CHUNK_OVERFETCH)
|
||||
)).all()
|
||||
except Exception:
|
||||
logger.warning("Rule semantic search failed", exc_info=True)
|
||||
return []
|
||||
|
||||
best: dict[int, tuple[float, object]] = {}
|
||||
for rule, dist in rows:
|
||||
score = 1.0 - float(dist)
|
||||
if rule.id not in best or score > best[rule.id][0]:
|
||||
best[rule.id] = (score, rule)
|
||||
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
|
||||
return ranked[:limit]
|
||||
|
||||
|
||||
async def backfill_rule_embeddings() -> None:
|
||||
"""Embed rules that have no current vectors. Runs at startup beside the
|
||||
note backfill; a CHUNKER_VERSION bump re-embeds rather than wiping."""
|
||||
from scribe.models.rulebook import Rule
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
current = select(RuleEmbedding.rule_id).where(
|
||||
RuleEmbedding.chunker_version == CHUNKER_VERSION
|
||||
)
|
||||
stale = (await session.execute(
|
||||
select(Rule.id, Rule.title, Rule.statement, Rule.when_to_apply)
|
||||
.where(Rule.deleted_at.is_(None), Rule.id.notin_(current))
|
||||
)).all()
|
||||
except Exception:
|
||||
logger.warning("Rule embedding backfill: failed to query rules", exc_info=True)
|
||||
return
|
||||
|
||||
if not stale:
|
||||
logger.info("Rule embedding backfill: all rules current at chunker v%d", CHUNKER_VERSION)
|
||||
return
|
||||
logger.info("Rule embedding backfill: embedding %d rule(s)", len(stale))
|
||||
for rule_id, title, statement, when_to_apply in stale:
|
||||
await upsert_rule_embedding(rule_id, title, statement, when_to_apply)
|
||||
|
||||
@@ -30,7 +30,7 @@ from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import shape_ledger as shape_ledger_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import label_shared_items, owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
|
||||
from scribe.services.note_usage import record_surfaced
|
||||
from scribe.services.supersession import superseded_ids
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
@@ -707,6 +707,7 @@ async def build_write_path_hint(
|
||||
stamp_shapes: list[tuple[str, str]] | None = None,
|
||||
repo_key: str = "",
|
||||
exclude_derive: list[str] | None = None,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||
|
||||
@@ -766,7 +767,8 @@ async def build_write_path_hint(
|
||||
"""
|
||||
cfg = await get_writepath_config(user_id)
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||
"stamped": [], "divergence": [], "derive": [], "derive_keys": []}
|
||||
"stamped": [], "divergence": [], "derive": [], "derive_keys": [],
|
||||
"rule_ids": []}
|
||||
path = (path or "").strip()
|
||||
if not cfg["enabled"] or not path:
|
||||
return empty
|
||||
@@ -1036,6 +1038,50 @@ async def build_write_path_hint(
|
||||
for arm, ids in by_arm.items():
|
||||
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
|
||||
|
||||
# ── Standing rules that may apply here (milestone 307) ──────────────
|
||||
#
|
||||
# A SUGGESTION, not a binding surface, and the distinction is the design
|
||||
# (D7): a rule BINDS by being tagged to an area the project works in,
|
||||
# resolved deterministically at enter_project. This arm reaches for
|
||||
# something weaker and still useful — a conditional rule whose trigger
|
||||
# resembles what is being written, noticed at the moment it is relevant
|
||||
# rather than by being resident in every session.
|
||||
#
|
||||
# CONDITIONAL ONLY. An always-on rule is already in the session; repeating
|
||||
# it here would be noise, and noise on a hint that fires on every write is
|
||||
# how a hint gets ignored.
|
||||
#
|
||||
# Fails open like every other arm: a rule hint must never break a write.
|
||||
rule_ids: list[int] = []
|
||||
try:
|
||||
already = set(exclude_rule_ids or [])
|
||||
hits = await semantic_search_rules(
|
||||
user_id, code or path, limit=2,
|
||||
threshold=cfg["threshold"], tier="conditional",
|
||||
)
|
||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||
for _score, rule in fresh:
|
||||
trigger = (rule.when_to_apply or "").strip()
|
||||
lines.append(
|
||||
f"Standing rule that may apply here — \u201c{rule.title}\u201d"
|
||||
+ (f" ({trigger})" if trigger else "")
|
||||
+ f". Read it with get_rule({rule.id}) before deciding it "
|
||||
"does not apply; it is not in this session's loaded set."
|
||||
)
|
||||
rule_ids.append(rule.id)
|
||||
if fresh:
|
||||
# retrieval_logs, NOT note_usage_events: that table's ids are
|
||||
# remapped on a backup restore, so a rule id there would return
|
||||
# attached to whatever note took that number. This one is never
|
||||
# restored, and `source` already separates the surfaces.
|
||||
record_retrieval(
|
||||
user_id=user_id, source="write_path_rule", query=code or path,
|
||||
threshold=cfg["threshold"], limit=2, project_id=project_id,
|
||||
is_task=None, results=fresh,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("write-path rule arm failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"context": "\n".join(lines),
|
||||
"note_ids": note_ids,
|
||||
@@ -1045,6 +1091,7 @@ async def build_write_path_hint(
|
||||
"divergence": divergence,
|
||||
"derive": derive,
|
||||
"derive_keys": [d["key"] for d in derive],
|
||||
"rule_ids": rule_ids,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -108,11 +109,19 @@ def record_retrieval(
|
||||
limit: int | None,
|
||||
project_id: int | None,
|
||||
is_task: bool | None,
|
||||
results: list[tuple[float, Note]],
|
||||
results: list[tuple[float, Any]],
|
||||
duration_ms: float | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget: record one retrieval call.
|
||||
|
||||
`results` needs only `.id` on each record, which is why it is not typed to
|
||||
Note: rules are retrieved too (milestone 307) and land here rather than in
|
||||
note_usage_events. That table's ids are REMAPPED on a backup restore, so a
|
||||
rule id written into it would come back attached to whatever note happened
|
||||
to take that number — silent corruption of the very evidence this exists to
|
||||
provide. retrieval_logs is not restored at all, so it has no such hazard,
|
||||
and `source` already distinguishes the surfaces.
|
||||
|
||||
Builds the payload inline (synchronously) then schedules the insert so the
|
||||
caller returns immediately. Never raises — telemetry must not affect search.
|
||||
"""
|
||||
|
||||
@@ -10,9 +10,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete as sql_delete, insert, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.system import System
|
||||
from scribe.models.rulebook import Rulebook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -223,7 +224,7 @@ async def delete_topic(topic_id: int, user_id: int) -> None:
|
||||
|
||||
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.models.rulebook import Rule, RuleRelation, rule_systems
|
||||
|
||||
|
||||
async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
|
||||
@@ -280,9 +281,161 @@ async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> No
|
||||
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
|
||||
|
||||
|
||||
# The vocabularies migration 0088's CHECK constraints enforce. Named here so
|
||||
# a caller can be corrected before the database refuses it (rule 36 keeps the
|
||||
# two in step; this keeps the error readable).
|
||||
TIERS = ("always_on", "conditional")
|
||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
|
||||
|
||||
def _valid_tier(tier: str) -> str:
|
||||
"""An unrecognised tier falls back to always_on — the SAFE direction.
|
||||
|
||||
Getting this wrong the other way would silently stop a rule binding, which
|
||||
is the one failure this whole milestone exists to prevent. A rule that
|
||||
preloads when it did not need to costs context; a rule that quietly stops
|
||||
preloading costs the behaviour it was written for.
|
||||
"""
|
||||
return tier if tier in TIERS else "always_on"
|
||||
|
||||
|
||||
def rule_brief(rule: Rule, **extra) -> dict:
|
||||
"""The shape a rule takes when it is SURFACED rather than opened.
|
||||
|
||||
One builder for every payload that hands rules to an agent, because there
|
||||
were three copies of this dict and they had already diverged — two carried
|
||||
`topic_id`, one didn't, and none carried the timestamps the model has held
|
||||
all along. That omission is why a rule written before the capability it
|
||||
duplicates was indistinguishable, at read time, from one still doing work
|
||||
(the FabledCurator case, note 3026).
|
||||
|
||||
`updated_at` is a DATE, not a stamp: the question it answers is "how old
|
||||
is this?", and a full ISO string across an always-on set is ~2k characters
|
||||
of payload for a precision nobody reads.
|
||||
|
||||
`why` and `how_to_apply` are deliberately NOT here — they are the depth a
|
||||
caller gets from get_rule, and putting them in every listing is the bloat
|
||||
this milestone is about.
|
||||
"""
|
||||
out = {
|
||||
"id": rule.id,
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"topic_id": rule.topic_id,
|
||||
"tier": rule.tier,
|
||||
"updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None,
|
||||
}
|
||||
# Attached only when present (#2483: never a null key that reads as a
|
||||
# capability the record doesn't have).
|
||||
if rule.when_to_apply:
|
||||
out["when_to_apply"] = rule.when_to_apply
|
||||
if rule.arose_from_id:
|
||||
out["arose_from_id"] = rule.arose_from_id
|
||||
out.update({k: v for k, v in extra.items() if v is not None})
|
||||
return out
|
||||
|
||||
|
||||
def _refresh_rule_embedding(rule: Rule) -> None:
|
||||
"""Re-index a rule after a write. Fire-and-forget, like the note twin.
|
||||
|
||||
Lazy import so this module doesn't pull in the embedder; every exception
|
||||
swallowed because a rule that SAVED must not fail on its index refresh —
|
||||
a stale vector costs a missed search hit, a raised exception costs the
|
||||
write. No running loop (unit tests, scripts) is ordinary, not an error.
|
||||
"""
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from scribe.services.embeddings import upsert_rule_embedding
|
||||
|
||||
asyncio.create_task(
|
||||
upsert_rule_embedding(
|
||||
rule.id, rule.title, rule.statement, rule.when_to_apply,
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # no running loop — a sync caller, not a failure
|
||||
except Exception: # noqa: BLE001 - never let indexing break a write
|
||||
logger.exception("embedding refresh failed for rule %s", rule.id)
|
||||
|
||||
|
||||
async def co_surfaced_partners(
|
||||
user_id: int, rule_ids: list[int], exclude_ids: set[int] | None = None,
|
||||
) -> list[Rule]:
|
||||
"""Rules that must arrive WITH the given ones, because they fail together.
|
||||
|
||||
This is the whole reason `co_surfaces` exists. Rule 144 was split off rule
|
||||
46 and folded back into it the same day, on the correct observation that
|
||||
"either rule could surface without the other and miss exposing a project to
|
||||
what the entire shape is intended to be." Merging was the only fix
|
||||
available; this is the fix that should have been available.
|
||||
|
||||
Two limits, both deliberate:
|
||||
|
||||
- Only rules the caller OWNS. An edge is not a back door into someone
|
||||
else's rulebook.
|
||||
- `exclude_ids` is honoured, and callers pass the project's SUPPRESSIONS.
|
||||
A project that explicitly muted a rule should not have it dragged back in
|
||||
by an edge — the suppression is a decision, and the edge does not
|
||||
outrank it.
|
||||
"""
|
||||
if not rule_ids:
|
||||
return []
|
||||
known = set(rule_ids) | (exclude_ids or set())
|
||||
async with async_session() as session:
|
||||
edges = (await session.execute(
|
||||
select(RuleRelation).where(
|
||||
RuleRelation.kind == "co_surfaces",
|
||||
or_(
|
||||
RuleRelation.from_rule_id.in_(rule_ids),
|
||||
RuleRelation.to_rule_id.in_(rule_ids),
|
||||
),
|
||||
)
|
||||
)).scalars().all()
|
||||
partners = {
|
||||
(edge.to_rule_id if edge.from_rule_id in known else edge.from_rule_id)
|
||||
for edge in edges
|
||||
} - known
|
||||
if not partners:
|
||||
return []
|
||||
# Ownership re-checked per partner rather than assumed from the edge.
|
||||
out = []
|
||||
for partner_id in sorted(partners):
|
||||
rule = await _fetch_owned_rule(session, partner_id, user_id)
|
||||
if rule is not None:
|
||||
out.append(rule)
|
||||
return out
|
||||
|
||||
|
||||
async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = None) -> dict:
|
||||
"""The full record, with its areas and edges attached.
|
||||
|
||||
ONE seam for both doors and every write path, so create, update and get
|
||||
cannot disagree about what a rule looks like coming back — the same
|
||||
reasoning as attach_relations for notes (#2859), and the same reasoning
|
||||
rule_brief exists for one level down.
|
||||
|
||||
`system_ids=None` means "leave the tags alone"; a list (including [])
|
||||
REPLACES them.
|
||||
"""
|
||||
if system_ids is not None:
|
||||
await set_rule_systems(rule.id, user_id, system_ids)
|
||||
data = rule.to_dict()
|
||||
systems = (await list_rule_systems([rule.id])).get(rule.id, [])
|
||||
relations = (await list_rule_relations([rule.id])).get(rule.id, [])
|
||||
# Attached only when present (#2483): an empty key reads as a capability
|
||||
# the record has and isn't using, which is a different claim.
|
||||
if systems:
|
||||
data["systems"] = systems
|
||||
if relations:
|
||||
data["relations"] = relations
|
||||
return data
|
||||
|
||||
|
||||
async def create_rule(
|
||||
topic_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
) -> Rule:
|
||||
async with async_session() as session:
|
||||
await _assert_topic_owned(session, topic_id, user_id)
|
||||
@@ -290,19 +443,24 @@ async def create_rule(
|
||||
topic_id=topic_id,
|
||||
title=title,
|
||||
statement=statement,
|
||||
when_to_apply=when_to_apply or None,
|
||||
tier=_valid_tier(tier),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
arose_from_id=arose_from_id or None,
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(rule)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
_refresh_rule_embedding(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def create_project_rule(
|
||||
project_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
) -> Rule:
|
||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||
|
||||
@@ -316,13 +474,17 @@ async def create_project_rule(
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
statement=statement,
|
||||
when_to_apply=when_to_apply or None,
|
||||
tier=_valid_tier(tier),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
arose_from_id=arose_from_id or None,
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(rule)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
_refresh_rule_embedding(rule)
|
||||
return rule
|
||||
|
||||
|
||||
@@ -454,6 +616,17 @@ async def list_always_on_rules(
|
||||
Rule.deleted_at.is_(None),
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
Rulebook.deleted_at.is_(None),
|
||||
# TIER (milestone 307). This is the SESSION-START call, made
|
||||
# before any project is in scope — there is no area vocabulary
|
||||
# to match a conditional rule against yet, so only the
|
||||
# unconditional tier belongs here. A conditional rule reaches a
|
||||
# session through enter_project (by area) or search (by
|
||||
# meaning), not by being resident.
|
||||
#
|
||||
# Behaviour is unchanged until rules are actually re-tiered:
|
||||
# `tier` defaults to always_on, so every existing rule still
|
||||
# arrives exactly as it did.
|
||||
Rule.tier == "always_on",
|
||||
)
|
||||
)
|
||||
if project_id:
|
||||
@@ -513,15 +686,175 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
if rule is None:
|
||||
return None
|
||||
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
|
||||
allowed = {
|
||||
"title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "arose_from_id",
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(rule, key, value)
|
||||
setattr(rule, key, _valid_tier(value) if key == "tier" else value)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
_refresh_rule_embedding(rule)
|
||||
return rule
|
||||
|
||||
|
||||
# ── Canon tags + typed edges (milestone 307) ───────────────────────────
|
||||
|
||||
async def set_rule_systems(
|
||||
rule_id: int, user_id: int, canonical_ids: list[int],
|
||||
) -> list[int] | None:
|
||||
"""Replace which global AREAS a rule is about. None if not owned.
|
||||
|
||||
Set-semantics like set_record_systems: the list given IS the state after,
|
||||
so an empty list clears the tags. Points at the canonical catalog, never a
|
||||
project's System — a family rule tagged to one project's row would bind
|
||||
itself to that project's vocabulary.
|
||||
"""
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.rulebook import rule_systems as rule_systems_t
|
||||
|
||||
async with async_session() as session:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
if rule is None:
|
||||
return None
|
||||
wanted = set(canonical_ids or [])
|
||||
if wanted:
|
||||
live = set((await session.execute(
|
||||
select(CanonicalSystem.id).where(
|
||||
CanonicalSystem.id.in_(wanted),
|
||||
CanonicalSystem.deleted_at.is_(None),
|
||||
)
|
||||
)).scalars().all())
|
||||
# Silently dropping an unknown id would leave the caller believing
|
||||
# a tag exists; keep only the live ones and report what stuck.
|
||||
wanted &= live
|
||||
await session.execute(
|
||||
sql_delete(rule_systems_t).where(rule_systems_t.c.rule_id == rule_id)
|
||||
)
|
||||
for canonical_id in sorted(wanted):
|
||||
await session.execute(
|
||||
insert(rule_systems_t).values(rule_id=rule_id, canonical_id=canonical_id)
|
||||
)
|
||||
await session.commit()
|
||||
return sorted(wanted)
|
||||
|
||||
|
||||
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||
"""The canon tags for a batch of rules, keyed by rule id.
|
||||
|
||||
Batched on purpose: the surfacing paths ask about a whole payload of rules
|
||||
at once, and one query per rule would turn every session start into an
|
||||
N+1.
|
||||
"""
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.rulebook import rule_systems as rule_systems_t
|
||||
|
||||
if not rule_ids:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(rule_systems_t.c.rule_id, CanonicalSystem.id, CanonicalSystem.name)
|
||||
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||
.where(
|
||||
rule_systems_t.c.rule_id.in_(rule_ids),
|
||||
CanonicalSystem.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(CanonicalSystem.order_index)
|
||||
)).all()
|
||||
out: dict[int, list[dict]] = {}
|
||||
for rule_id, canonical_id, name in rows:
|
||||
out.setdefault(rule_id, []).append({"id": canonical_id, "name": name})
|
||||
return out
|
||||
|
||||
|
||||
async def add_rule_relation(
|
||||
user_id: int, from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
||||
) -> RuleRelation | None:
|
||||
"""Draw a typed edge between two rules. None if either isn't owned.
|
||||
|
||||
Both ends are ownership-checked: an edge is only meaningful if the drawer
|
||||
can see both rules, and a one-sided edge would surface a rule the caller
|
||||
has no business reading.
|
||||
|
||||
Idempotent — re-drawing an existing edge returns it rather than raising, so
|
||||
a true-up pass can be re-run without cleaning up first.
|
||||
"""
|
||||
if kind not in RELATION_KINDS:
|
||||
raise ValueError(f"kind must be one of {RELATION_KINDS}, got {kind!r}")
|
||||
if from_rule_id == to_rule_id:
|
||||
raise ValueError("a rule cannot relate to itself")
|
||||
async with async_session() as session:
|
||||
for rid in (from_rule_id, to_rule_id):
|
||||
if await _fetch_owned_rule(session, rid, user_id) is None:
|
||||
return None
|
||||
existing = await session.scalar(
|
||||
select(RuleRelation).where(
|
||||
RuleRelation.from_rule_id == from_rule_id,
|
||||
RuleRelation.to_rule_id == to_rule_id,
|
||||
RuleRelation.kind == kind,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
relation = RuleRelation(
|
||||
from_rule_id=from_rule_id, to_rule_id=to_rule_id,
|
||||
kind=kind, note=note or None,
|
||||
)
|
||||
session.add(relation)
|
||||
await session.commit()
|
||||
await session.refresh(relation)
|
||||
return relation
|
||||
|
||||
|
||||
async def remove_rule_relation(user_id: int, relation_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
relation = await session.get(RuleRelation, relation_id)
|
||||
if relation is None:
|
||||
return False
|
||||
if await _fetch_owned_rule(session, relation.from_rule_id, user_id) is None:
|
||||
return False
|
||||
await session.delete(relation)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_rule_relations(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||
"""Edges touching a batch of rules, keyed by rule id.
|
||||
|
||||
`co_surfaces` is reported from BOTH ends off a single stored row — it means
|
||||
"these fail together", which is not a claim with a direction. The other two
|
||||
are directional and are reported as stored, with `direction` naming which
|
||||
end this rule is: an override read from the wrong end would invert what it
|
||||
says.
|
||||
"""
|
||||
if not rule_ids:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(RuleRelation).where(
|
||||
(RuleRelation.from_rule_id.in_(rule_ids))
|
||||
| (RuleRelation.to_rule_id.in_(rule_ids))
|
||||
)
|
||||
)).scalars().all()
|
||||
out: dict[int, list[dict]] = {}
|
||||
wanted = set(rule_ids)
|
||||
for relation in rows:
|
||||
if relation.from_rule_id in wanted:
|
||||
out.setdefault(relation.from_rule_id, []).append({
|
||||
"id": relation.id, "kind": relation.kind,
|
||||
"rule_id": relation.to_rule_id,
|
||||
"direction": "outgoing", "note": relation.note or "",
|
||||
})
|
||||
if relation.to_rule_id in wanted:
|
||||
out.setdefault(relation.to_rule_id, []).append({
|
||||
"id": relation.id, "kind": relation.kind,
|
||||
"rule_id": relation.from_rule_id,
|
||||
"direction": "incoming", "note": relation.note or "",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def delete_rule(rule_id: int, user_id: int) -> None:
|
||||
async with async_session() as session:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
@@ -533,7 +866,6 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
|
||||
|
||||
# ── Subscriptions + get_applicable_rules ───────────────────────────────
|
||||
|
||||
from sqlalchemy import insert, delete as sql_delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
@@ -802,10 +1134,13 @@ async def get_applicable_rules(
|
||||
# Applicable rules (limit + 1 so we can detect truncation). Filter
|
||||
# in SQL so truncation reflects the post-suppression count, not the
|
||||
# raw subscription count.
|
||||
# Selects the ENTITY, not a column list: rule_brief is the one place
|
||||
# that decides which fields a surfaced rule carries, and a column list
|
||||
# here would be a second such decision to keep in step. The row count
|
||||
# is bounded by `limit`, so this is a listing, not a scan.
|
||||
rules_q = (
|
||||
select(
|
||||
Rule.id, Rule.title, Rule.statement,
|
||||
RulebookTopic.id.label("topic_id"),
|
||||
Rule,
|
||||
RulebookTopic.title.label("topic_title"),
|
||||
Rulebook.id.label("rulebook_id"),
|
||||
Rulebook.title.label("rulebook_title"),
|
||||
@@ -835,21 +1170,41 @@ async def get_applicable_rules(
|
||||
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
||||
if suppressed_topic_ids:
|
||||
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
|
||||
# TIER (milestone 307). always_on rules are resident, as every rule was
|
||||
# before tiers existed. A conditional rule is REACHABLE, and reaches
|
||||
# this project only when it is tagged to an area this project actually
|
||||
# works in — a deterministic tag match, never a similarity score, so
|
||||
# bindingness never depends on a ranking (D7).
|
||||
#
|
||||
# Applied in SQL rather than by filtering afterwards, so `limit` counts
|
||||
# the rules that will actually be surfaced instead of counting rules
|
||||
# that are about to be dropped.
|
||||
project_area_ids = (await session.execute(
|
||||
select(System.canonical_id).where(
|
||||
System.project_id == project_id,
|
||||
System.canonical_id.is_not(None),
|
||||
System.deleted_at.is_(None),
|
||||
System.status == "active",
|
||||
).distinct()
|
||||
)).scalars().all()
|
||||
reachable = select(rule_systems.c.rule_id).where(
|
||||
rule_systems.c.canonical_id.in_(project_area_ids)
|
||||
) if project_area_ids else None
|
||||
tier_clause = (Rule.tier == "always_on")
|
||||
if reachable is not None:
|
||||
tier_clause = or_(tier_clause, Rule.id.in_(reachable))
|
||||
rules_q = rules_q.where(tier_clause)
|
||||
rule_rows = (await session.execute(rules_q)).all()
|
||||
truncated = len(rule_rows) > limit
|
||||
rules = [
|
||||
{
|
||||
"id": rid, "title": rtitle, "statement": stmt,
|
||||
"topic_id": ti, "topic_title": tt,
|
||||
"rulebook_id": rbi, "rulebook_title": rbt,
|
||||
}
|
||||
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
|
||||
rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt)
|
||||
for rule, tt, rbi, rbt in rule_rows[:limit]
|
||||
]
|
||||
|
||||
# Project-scoped rules — verifies ownership via Project.user_id.
|
||||
from scribe.models.project import Project
|
||||
proj_rules_q = (
|
||||
select(Rule.id, Rule.title, Rule.statement)
|
||||
select(Rule)
|
||||
.join(Project, Rule.project_id == Project.id)
|
||||
.where(
|
||||
Project.user_id == user_id,
|
||||
@@ -859,11 +1214,39 @@ async def get_applicable_rules(
|
||||
)
|
||||
.order_by(Rule.order_index, Rule.title)
|
||||
)
|
||||
if reachable is not None:
|
||||
proj_rules_q = proj_rules_q.where(
|
||||
or_(Rule.tier == "always_on", Rule.id.in_(reachable))
|
||||
)
|
||||
else:
|
||||
proj_rules_q = proj_rules_q.where(Rule.tier == "always_on")
|
||||
proj_rule_rows = (await session.execute(proj_rules_q)).all()
|
||||
project_rules = [
|
||||
{"id": rid, "title": rtitle, "statement": stmt}
|
||||
for rid, rtitle, stmt in proj_rule_rows
|
||||
]
|
||||
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
|
||||
|
||||
# Edges travel with the rules they belong to (milestone 307).
|
||||
#
|
||||
# A co_surfaces partner that was not otherwise selected is ADDED, because a
|
||||
# rule that arrives without the half it fails with is the failure the edge
|
||||
# was created to prevent. Suppressions are passed as exclusions so an
|
||||
# explicit mute still wins over an edge.
|
||||
surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules]
|
||||
partners = await co_surfaced_partners(
|
||||
user_id, surfaced_ids, exclude_ids=set(suppressed_rule_ids),
|
||||
)
|
||||
for partner in partners:
|
||||
rules.append(rule_brief(partner, via="co_surfaces"))
|
||||
surfaced_ids.append(partner.id)
|
||||
|
||||
# Relations on every surfaced rule, so a reader can see that an override
|
||||
# exists rather than discovering the contradiction by acting on the wrong
|
||||
# one. Areas too — they are why a conditional rule is here at all.
|
||||
edges = await list_rule_relations(surfaced_ids)
|
||||
areas = await list_rule_systems(surfaced_ids)
|
||||
for brief in (*rules, *project_rules):
|
||||
if edges.get(brief["id"]):
|
||||
brief["relations"] = edges[brief["id"]]
|
||||
if areas.get(brief["id"]):
|
||||
brief["systems"] = areas[brief["id"]]
|
||||
|
||||
return {
|
||||
"rules": rules,
|
||||
|
||||
@@ -14,38 +14,99 @@ from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.system import RecordSystem, System
|
||||
from scribe.services import access
|
||||
from scribe.services import canonical_systems as canonical_systems_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# The standard cross-project vocabulary (#2798): names that mean the same
|
||||
# thing in every project, so a starter set reads the same everywhere. The
|
||||
# bootstrap ask (mcp/tools/systems) names them; the inception seed
|
||||
# (services/inception, milestone 297) mints them. Charters are deliberately
|
||||
# generic — a project refines them as its own records accrue.
|
||||
STANDARD_SYSTEMS: tuple[tuple[str, str], ...] = (
|
||||
("CI & Release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
|
||||
("Auth & Access", "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
|
||||
("Data Model & Storage", "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
|
||||
("API Surface", "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
|
||||
("UI & Design", "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
|
||||
("Import & Export", "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
|
||||
("Background Jobs", "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
|
||||
("Observability", "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
|
||||
)
|
||||
def local_name_key(name: str) -> str:
|
||||
"""The within-project uniqueness key: case and spacing, nothing else.
|
||||
|
||||
Deliberately weaker than `canonical_slug`. This one answers "is this the
|
||||
same System I already have here", where the operator's own spelling is the
|
||||
thing being compared; the canonical slug answers "is this the same AREA as
|
||||
some other project's System", where spelling is exactly what must be
|
||||
ignored.
|
||||
"""
|
||||
return " ".join(name.split()).lower()
|
||||
|
||||
|
||||
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
||||
"""What BOTH doors must know before minting a System name (milestone 307).
|
||||
|
||||
Lived in the MCP tool alone until now, which is how the web UI shipped
|
||||
without a gate the agent surface enforced (#2482). One service function, so
|
||||
the two doors cannot answer the same question differently (rule 33).
|
||||
|
||||
Returns `{"duplicate": …|None, "canonical": …|None}`:
|
||||
|
||||
- `duplicate` — this project already has a System by that name. A hard stop
|
||||
for the caller: a second one splits the area's records across two piles.
|
||||
- `canonical` — the global catalog covers this area, with a `basis`.
|
||||
`exact` is mechanical and safe to apply on the spot; `overlap` is a
|
||||
judgment call and must be OFFERED, never applied. Neither ever blocks:
|
||||
an unmatched name is a project-specific area, which is legitimate.
|
||||
|
||||
Fails open on both arms — a naming aid must never break a create.
|
||||
"""
|
||||
out: dict = {"duplicate": None, "canonical": None}
|
||||
key = local_name_key(name)
|
||||
if not key:
|
||||
return out
|
||||
try:
|
||||
for existing in await list_systems(user_id, project_id, include_archived=True):
|
||||
if local_name_key(existing.name) == key:
|
||||
out["duplicate"] = {"id": existing.id, "name": existing.name}
|
||||
return out
|
||||
except Exception:
|
||||
logger.debug("system name assessment: local scan failed", exc_info=True)
|
||||
return out
|
||||
try:
|
||||
exact = await canonical_systems_svc.find_by_name(name)
|
||||
if exact is not None:
|
||||
out["canonical"] = {
|
||||
"id": exact.id, "name": exact.name, "basis": "exact",
|
||||
}
|
||||
return out
|
||||
# No exact hit: fall back to the same overlap scoring the review
|
||||
# surface uses, so a create-time offer and a later proposal never
|
||||
# disagree about which area a name resembles.
|
||||
near = await canonical_systems_svc.best_overlap(name)
|
||||
if near is not None:
|
||||
out["canonical"] = near
|
||||
except Exception:
|
||||
logger.debug("system name assessment: catalog lookup failed", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
async def standard_systems() -> list[tuple[str, str]]:
|
||||
"""The standard cross-project vocabulary (#2798) as (name, charter) pairs.
|
||||
|
||||
Reads the GLOBAL canonical catalog (milestone 307). This was a tuple
|
||||
constant in this module until the catalog became a table: a constant
|
||||
cannot be a foreign key, so nothing outside a project could reference an
|
||||
area, and the list only ever applied on the inception-seed path — which is
|
||||
how three spellings of "CI & Release" reached one instance anyway.
|
||||
"""
|
||||
return [(entry.name, entry.description or "") for entry in
|
||||
await canonical_systems_svc.list_canonical_systems()]
|
||||
|
||||
|
||||
async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
|
||||
"""Mint the standard starter set for a project that has NO Systems yet
|
||||
(milestone 297). Idempotent: a project with any System — the vocabulary
|
||||
already started, standard or not — gets nothing; the duplicate gate and
|
||||
the project's own judgment take it from there. [] without write access."""
|
||||
the project's own judgment take it from there. [] without write access.
|
||||
|
||||
Seeded Systems are mapped to their catalog entry as they are created, so a
|
||||
project born this way needs no reconciliation pass later."""
|
||||
if await list_systems(user_id, project_id, include_archived=True):
|
||||
return []
|
||||
out: list[System] = []
|
||||
for index, (name, charter) in enumerate(STANDARD_SYSTEMS):
|
||||
for index, entry in enumerate(await canonical_systems_svc.list_canonical_systems()):
|
||||
system = await create_system(
|
||||
user_id, project_id, name, description=charter, order_index=index,
|
||||
user_id, project_id, entry.name, description=entry.description,
|
||||
order_index=index, canonical_id=entry.id,
|
||||
)
|
||||
if system is None:
|
||||
break
|
||||
@@ -60,8 +121,14 @@ async def create_system(
|
||||
description: str | None = None,
|
||||
color: str | None = None,
|
||||
order_index: int = 0,
|
||||
canonical_id: int | None = None,
|
||||
) -> System | None:
|
||||
"""Create a System. None if the user can't write the project."""
|
||||
"""Create a System. None if the user can't write the project.
|
||||
|
||||
`canonical_id` maps the new System onto the global catalog; leaving it None
|
||||
is fine — an unmapped System is fully usable, and the mapping can be
|
||||
proposed later (services/canonical_systems.propose_mappings).
|
||||
"""
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
@@ -72,6 +139,7 @@ async def create_system(
|
||||
description=description,
|
||||
color=color,
|
||||
order_index=order_index,
|
||||
canonical_id=canonical_id,
|
||||
)
|
||||
session.add(system)
|
||||
await session.commit()
|
||||
@@ -110,6 +178,10 @@ async def list_systems(
|
||||
|
||||
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
|
||||
"""Update a System if the user can write its project."""
|
||||
# canonical_id is deliberately NOT settable here: canonical_systems.
|
||||
# set_system_canonical is its single writer, because it also validates the
|
||||
# catalog entry is live. Two entry points onto one column is the drift this
|
||||
# table exists to end.
|
||||
allowed = {"name", "description", "color", "status", "order_index"}
|
||||
async with async_session() as session:
|
||||
system = await session.get(System, system_id)
|
||||
|
||||
@@ -82,3 +82,24 @@ def _no_supersession():
|
||||
with patch("scribe.services.plugin_context.superseded_ids",
|
||||
AsyncMock(return_value=set())):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_rule_arm():
|
||||
"""Stub the write-path hint's standing-RULES arm (milestone 307).
|
||||
|
||||
Autouse, and deliberately so. The arm calls semantic_search_rules, which
|
||||
loads the embedding model — so every unrelated plugin-context test that
|
||||
already stubs the NOTES search would otherwise pull a real model into a
|
||||
unit test through the one arm it forgot to stub. The forty-odd existing
|
||||
call sites should not each have to learn about a new arm.
|
||||
|
||||
The arm's own behaviour is covered where it belongs: the document shape in
|
||||
tests/test_services_rule_embeddings.py, the surfacing rules against real
|
||||
Postgres in tests/test_integration_rule_surfacing.py, and the hook's dedup
|
||||
channel in tests/test_write_path_trigger.py. A test that wants the arm
|
||||
live can re-patch it.
|
||||
"""
|
||||
with patch("scribe.services.plugin_context.semantic_search_rules",
|
||||
AsyncMock(return_value=[])):
|
||||
yield
|
||||
|
||||
+8
-2
@@ -132,7 +132,9 @@ def fake_milestone(**attrs) -> MagicMock:
|
||||
|
||||
|
||||
def fake_system(**attrs) -> MagicMock:
|
||||
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
|
||||
return _with_defaults(
|
||||
{"id": 1, "name": "Reader", "project_id": 5, "canonical_id": None}, attrs,
|
||||
)
|
||||
|
||||
|
||||
def fake_rulebook(**attrs) -> MagicMock:
|
||||
@@ -151,8 +153,12 @@ def fake_topic(**attrs) -> MagicMock:
|
||||
|
||||
def fake_rule(**attrs) -> MagicMock:
|
||||
return _with_defaults({
|
||||
"id": 1, "topic_id": 10, "title": "dev is home",
|
||||
"id": 1, "topic_id": 10, "project_id": None, "title": "dev is home",
|
||||
"statement": "Work directly on dev", "why": "", "how_to_apply": "",
|
||||
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||
# rule_brief would attach both keys on every stand-in.
|
||||
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
||||
"order_index": 0, "created_at": _now(), "updated_at": _now(),
|
||||
}, attrs)
|
||||
|
||||
|
||||
+18
-5
@@ -57,9 +57,22 @@ def test_normalize_choices_is_canonical_and_complete():
|
||||
"design_system_id": None, "seed_systems": False}
|
||||
|
||||
|
||||
def test_standard_systems_vocabulary_is_one_list_for_ask_and_seed():
|
||||
from scribe.mcp.tools.systems import _STANDARD_SYSTEMS
|
||||
from scribe.services.systems import STANDARD_SYSTEMS
|
||||
assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS)
|
||||
assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS)
|
||||
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
||||
"""The vocabulary moved from a module constant to the global catalog table
|
||||
(milestone 307): a constant cannot be a foreign key, so nothing outside a
|
||||
project could reference an area. The seed and the bootstrap ask must both
|
||||
read the table, or the list they show and the list they mint diverge."""
|
||||
import inspect
|
||||
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), (
|
||||
"the constant is gone — the catalog table is the single source"
|
||||
)
|
||||
source = inspect.getsource(systems_svc.seed_standard_systems)
|
||||
assert "list_canonical_systems" in source
|
||||
assert "canonical_id=entry.id" in source, (
|
||||
"a seeded System must be mapped as it is created, or a project born "
|
||||
"from the standard set still needs a reconciliation pass"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import inception as inception_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
@@ -62,7 +63,11 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
})
|
||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||
assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_SYSTEMS)
|
||||
catalog = await canonical_svc.list_canonical_systems()
|
||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||
# Seeded Systems come out mapped, not needing a later reconciliation.
|
||||
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||
|
||||
# The exclusion is total: the project's always-on set is empty, the
|
||||
# departure is named, the subscription binds.
|
||||
@@ -81,7 +86,7 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||
assert again["effects"]["systems_seeded"] == []
|
||||
assert len(await systems_svc.list_systems(owner, pid)) == len(systems_svc.STANDARD_SYSTEMS)
|
||||
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
||||
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5).
|
||||
|
||||
What mocks can't prove, and what this milestone must not get wrong:
|
||||
|
||||
1. **Nothing stops binding.** A rule with no tier, no areas and no edges
|
||||
behaves exactly as it did before tiers existed. That is the one failure this
|
||||
whole design must not produce, and it is asserted first.
|
||||
2. A conditional rule is invisible to a project that doesn't work in its area,
|
||||
and arrives — binding, not suggested — to one that does.
|
||||
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||
that made merging rule 144 into rule 46 look like the only fix.
|
||||
4. An explicit suppression outranks an edge.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world():
|
||||
"""A project with TWO rulebooks, because the two payloads are different sets.
|
||||
|
||||
`list_always_on_rules` covers always-on rulebooks; `get_applicable_rules`
|
||||
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
||||
assert nothing, so the fixture carries one of each and every test says
|
||||
which payload it is about.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "surfacing_owner")
|
||||
project = Project(user_id=owner.id, title="Surfacing target")
|
||||
s.add(project)
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "pid": project.id}
|
||||
await s.commit()
|
||||
|
||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||
async with async_session() as s:
|
||||
rb = await s.get(Rulebook, always.id)
|
||||
rb.always_on = True
|
||||
await s.commit()
|
||||
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||
await rulebooks_svc.create_rule(
|
||||
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
||||
)
|
||||
|
||||
book = await rulebooks_svc.create_rulebook(ids["owner"], "Subscribed practices")
|
||||
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
|
||||
plain = await rulebooks_svc.create_rule(
|
||||
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.",
|
||||
)
|
||||
await rulebooks_svc.subscribe_project(
|
||||
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"],
|
||||
)
|
||||
ids.update({
|
||||
"always": always.id, "always_topic": always_topic.id,
|
||||
"book": book.id, "topic": topic.id, "plain": plain.id,
|
||||
})
|
||||
return ids
|
||||
|
||||
|
||||
async def _titles(ids) -> set[str]:
|
||||
applicable = await rulebooks_svc.get_applicable_rules(ids["pid"], ids["owner"])
|
||||
return {r["title"] for r in applicable["rules"]}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_rule_with_no_tier_no_areas_and_no_edges_binds_exactly_as_before(world):
|
||||
"""THE compatibility guarantee. An install upgrades and every rule it
|
||||
already had keeps arriving — no tier set, no areas, no edges, still bound.
|
||||
Getting this wrong would silently stop enforcing rules people rely on,
|
||||
which is worse than any amount of payload bloat."""
|
||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
||||
assert "dev is home" in {r.title for r in always_on}
|
||||
assert "Between batches, keep stacking" in await _titles(world)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_conditional_rule_is_reachable_not_resident(world):
|
||||
"""It leaves the session-start payload entirely — that is the point of the
|
||||
tier — and it does NOT reach a project with no matching area."""
|
||||
# In the ALWAYS-ON book: the tier alone keeps it out of the session-start
|
||||
# payload, which is the whole point of the tier.
|
||||
resident = await rulebooks_svc.create_rule(
|
||||
world["always_topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||
when_to_apply="when cutting a release", tier="conditional",
|
||||
)
|
||||
assert resident.tier == "conditional"
|
||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
||||
assert "Release tagging" not in {r.title for r in always_on}
|
||||
|
||||
# In the SUBSCRIBED book, untagged: the project has no area to reach it by,
|
||||
# so it stays out of the project payload too. Absent for a DIFFERENT reason
|
||||
# than above, which is why both are asserted.
|
||||
await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Untagged conditional", "No area yet.",
|
||||
when_to_apply="sometime", tier="conditional",
|
||||
)
|
||||
assert "Untagged conditional" not in await _titles(world)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
||||
"""The payoff: the tag match carries it in deterministically. The project
|
||||
reaches the area through its own System's canonical_id — its local NAME is
|
||||
irrelevant, which is the whole reason the catalog exists."""
|
||||
area = await canonical_svc.find_by_name("CI & Release")
|
||||
assert area is not None, "migration 0087 seeds the standard vocabulary"
|
||||
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||
when_to_apply="when cutting a release", tier="conditional",
|
||||
)
|
||||
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
||||
|
||||
# Still absent: the project has no Systems at all yet.
|
||||
assert "Release tagging" not in await _titles(world)
|
||||
|
||||
# The project names the area with its OWN word, mapped to the same canon.
|
||||
local = await systems_svc.create_system(
|
||||
world["owner"], world["pid"], "CI & runners", description="ours",
|
||||
)
|
||||
await canonical_svc.set_system_canonical(world["owner"], local.id, area.id)
|
||||
|
||||
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"]
|
||||
assert hit, "a tagged conditional rule must bind a project working in that area"
|
||||
assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
"""Rule 144 was split off rule 46 and folded back the same day because
|
||||
"either rule could surface without the other". This is the edge that makes
|
||||
that unnecessary: the partner arrives even though nothing else selected
|
||||
it, and says why it is here."""
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Version names are labels",
|
||||
"A name decides nothing.",
|
||||
when_to_apply="when naming a build", tier="conditional",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
note="they fail together",
|
||||
)
|
||||
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
hit = [r for r in surfaced["rules"] if r["title"] == "Version names are labels"]
|
||||
assert hit, "a co_surfaces partner must arrive with its other half"
|
||||
assert hit[0]["via"] == "co_surfaces"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_suppression_outranks_an_edge(world):
|
||||
"""The edge says these belong together; the suppression says this project
|
||||
does not want that one. An explicit decision beats an inferred one."""
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||
tier="conditional",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
)
|
||||
await rulebooks_svc.suppress_rule_for_project(
|
||||
world["pid"], partner.id, world["owner"],
|
||||
)
|
||||
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
assert "Muted partner" not in {r["title"] for r in surfaced["rules"]}
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Tests for MCP rulebook tools — patches the service layer."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from tests.helpers import FakeMCP, fake_rule, fake_rulebook, fake_topic
|
||||
@@ -48,11 +48,25 @@ async def test_get_rulebook_raises_when_not_found():
|
||||
await get_rulebook(rulebook_id=999)
|
||||
|
||||
|
||||
def _plain_detail():
|
||||
"""Stub the rule_detail seam these tool tests are not about.
|
||||
|
||||
create/update/get_rule now return through services.rulebooks.rule_detail,
|
||||
which reads the rule's areas and edges from the database. These are unit
|
||||
tests with no database, and what they assert is that the TOOL forwards the
|
||||
right arguments — so the seam is stubbed to the plain record, the same way
|
||||
they already stub the create/update calls themselves.
|
||||
"""
|
||||
async def _detail(_uid, rule, _system_ids=None):
|
||||
return rule.to_dict()
|
||||
return patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", _detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_passes_required_fields():
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock):
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_rule
|
||||
await create_rule(
|
||||
topic_id=10, title="dev is home", statement="Work directly on dev",
|
||||
@@ -84,7 +98,8 @@ async def test_create_rule_force_bypasses_duplicate_gate():
|
||||
find_mock = AsyncMock()
|
||||
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
|
||||
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
|
||||
AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))):
|
||||
AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \
|
||||
_plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_rule
|
||||
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
|
||||
assert out["id"] == 5
|
||||
@@ -95,7 +110,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
|
||||
async def test_update_rule_only_sends_non_default_fields():
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock):
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import update_rule
|
||||
await update_rule(rule_id=1, statement="new statement")
|
||||
args, kwargs = mock.call_args
|
||||
@@ -169,7 +184,7 @@ def test_register_attaches_all_sixteen_tools():
|
||||
mcp = FakeMCP()
|
||||
|
||||
register(mcp)
|
||||
assert len(mcp.names) == 24 # +exclude/include_always_on_rulebook (milestone 297)
|
||||
assert len(mcp.names) == 26 # +relate_rules/unrelate_rules (milestone 307)
|
||||
# spot-check a few names
|
||||
assert "list_rulebooks" in mcp.names
|
||||
assert "create_rule" in mcp.names
|
||||
@@ -239,7 +254,7 @@ async def test_update_rulebook_omits_always_on_when_none():
|
||||
async def test_create_project_rule_passes_required_fields():
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
@@ -257,7 +272,7 @@ async def test_create_project_rule_passes_required_fields():
|
||||
async def test_create_project_rule_derives_title_from_statement():
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
@@ -272,7 +287,7 @@ async def test_create_project_rule_derives_title_from_statement():
|
||||
async def test_create_project_rule_uses_explicit_title_when_given():
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
@@ -325,3 +340,47 @@ async def test_unsuppress_topic_for_project_passes_through():
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
|
||||
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
|
||||
|
||||
|
||||
# ── Typed edges between rules (milestone 307) ───────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relate_rules_forwards_the_kind_and_the_why():
|
||||
"""The edge exists so a shape stops being merged into one row. The `note`
|
||||
travels with it for the same reason a rule carries `why`: whoever later
|
||||
decides whether the edge still holds needs the reasoning."""
|
||||
relation = MagicMock()
|
||||
relation.to_dict.return_value = {"id": 9, "kind": "co_surfaces"}
|
||||
mock = AsyncMock(return_value=relation)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock):
|
||||
from scribe.mcp.tools.rulebooks import relate_rules
|
||||
out = await relate_rules(
|
||||
from_rule_id=46, to_rule_id=144, kind="co_surfaces",
|
||||
note="a stale channel tag and an unparseable version both read as "
|
||||
"no update available",
|
||||
)
|
||||
assert out["id"] == 9
|
||||
args = mock.call_args.args
|
||||
assert args[0] == 7 and args[1] == 46 and args[2] == 144
|
||||
assert args[3] == "co_surfaces"
|
||||
assert "no update available" in args[4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relate_rules_raises_when_either_end_is_not_yours():
|
||||
"""The service returns None when it cannot see both rules — a one-sided
|
||||
edge would surface a rule the caller has no business reading."""
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation",
|
||||
AsyncMock(return_value=None)):
|
||||
from scribe.mcp.tools.rulebooks import relate_rules
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await relate_rules(from_rule_id=1, to_rule_id=2, kind="overrides")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrelate_rules_raises_when_the_edge_is_gone():
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation",
|
||||
AsyncMock(return_value=False)):
|
||||
from scribe.mcp.tools.rulebooks import unrelate_rules
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await unrelate_rules(relation_id=99)
|
||||
|
||||
@@ -5,26 +5,86 @@ import pytest
|
||||
from tests.helpers import fake_note, fake_system
|
||||
|
||||
|
||||
# The name assessment both doors run before minting (milestone 307). A test
|
||||
# that patches systems_svc wholesale must stub it, or the awaited MagicMock
|
||||
# raises — this shape is the "nothing matched" answer.
|
||||
_NO_MATCH = {"duplicate": None, "canonical": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_returns_dict():
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
|
||||
svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
result = await create_system(project_id=5, name="Reader", description="pdf reader")
|
||||
assert result["name"] == "Reader"
|
||||
# An unmatched name is a project-specific area: created, no offer, no fuss.
|
||||
assert "canonical_suggestion" not in result and "canonical_note" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_no_access_raises():
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
|
||||
svc.create_system = AsyncMock(return_value=None)
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
with pytest.raises(ValueError):
|
||||
await create_system(project_id=5, name="Reader")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
|
||||
"""The two bases must behave differently, and this is where it is decided.
|
||||
|
||||
`exact` differs from the catalog name only in spelling, so it is APPLIED —
|
||||
that is the mechanical case the catalog exists to collapse. `overlap` is a
|
||||
judgment call, so it is only OFFERED: applying it silently is how a
|
||||
cross-project rule ends up surfacing in the wrong project.
|
||||
"""
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value={
|
||||
"duplicate": None,
|
||||
"canonical": {"id": 3, "name": "CI & Release", "basis": "exact"},
|
||||
})
|
||||
svc.create_system = AsyncMock(return_value=fake_system(name="CI and Release"))
|
||||
exact = await create_system(project_id=5, name="CI and Release")
|
||||
assert svc.create_system.await_args.kwargs["canonical_id"] == 3
|
||||
assert "canonical_note" in exact and "canonical_suggestion" not in exact
|
||||
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value={
|
||||
"duplicate": None,
|
||||
"canonical": {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33},
|
||||
})
|
||||
svc.create_system = AsyncMock(return_value=fake_system(id=9, name="CI & runners"))
|
||||
similar = await create_system(project_id=5, name="CI & runners")
|
||||
assert svc.create_system.await_args.kwargs["canonical_id"] is None
|
||||
assert similar["canonical_suggestion"]["id"] == 3
|
||||
assert "map_system_to_canonical(9, 3)" in similar["canonical_suggestion"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_duplicate_names_the_existing_one_and_creates_nothing():
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value={
|
||||
"duplicate": {"id": 4, "name": "Reader"}, "canonical": None,
|
||||
})
|
||||
svc.create_system = AsyncMock()
|
||||
result = await create_system(project_id=5, name="reader")
|
||||
assert result["duplicate"] is True and result["existing_id"] == 4
|
||||
assert "Reader" in result["message"]
|
||||
svc.create_system.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_system_splits_records_by_kind():
|
||||
issue = MagicMock(); issue.to_dict.return_value = {"id": 10}; issue.task_kind = "issue"; issue.status = "todo"
|
||||
@@ -113,6 +173,15 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
|
||||
patch("scribe.mcp.tools.systems.notes_svc") as notes:
|
||||
svc.list_systems = AsyncMock(return_value=[])
|
||||
notes.list_notes = AsyncMock(return_value=(recent, 282))
|
||||
# The standard names come from the GLOBAL catalog now (milestone 307),
|
||||
# not a module constant — so the ask reads them through the service.
|
||||
# That the SEEDED vocabulary is these eight is migration 0087's
|
||||
# business, asserted against a real database in the inception
|
||||
# integration test; what belongs here is that whatever the catalog
|
||||
# holds reaches the ask verbatim.
|
||||
svc.standard_systems = AsyncMock(return_value=[
|
||||
("CI & Release", "..."), ("Auth & Access", "..."),
|
||||
])
|
||||
hint = await untagged_systems_hint(1, 5)
|
||||
assert "282 records" in hint
|
||||
assert "Fix scrape retry backoff" in hint # the project's own evidence
|
||||
@@ -126,6 +195,25 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
|
||||
assert "no Systems yet" not in hint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_ask_still_asks_when_the_catalog_is_unreachable():
|
||||
"""The standard names are an AID to the question, not the question. If the
|
||||
catalog read fails (or an install has an empty one), the ask must still
|
||||
carry the project's evidence and demand the same deliverable — degrading to
|
||||
a weaker nudge is acceptable, going silent is not."""
|
||||
from scribe.mcp.tools.systems import untagged_systems_hint
|
||||
recent = [fake_note(title="Fix scrape retry backoff")]
|
||||
with patch("scribe.mcp.tools.systems.systems_svc") as svc, \
|
||||
patch("scribe.mcp.tools.systems.notes_svc") as notes:
|
||||
svc.list_systems = AsyncMock(return_value=[])
|
||||
notes.list_notes = AsyncMock(return_value=(recent, 282))
|
||||
svc.standard_systems = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
hint = await untagged_systems_hint(1, 5)
|
||||
assert hint is not None
|
||||
assert "282 records" in hint and "create_system" in hint
|
||||
assert "3-6" in hint and "without asking permission" in hint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_ask_stays_quiet_below_threshold_and_fails_open():
|
||||
from scribe.mcp.tools import systems as tools
|
||||
@@ -155,33 +243,11 @@ async def test_populated_vocabulary_never_counts_records():
|
||||
notes.list_notes.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_same_normalized_name_is_duplicate_gated():
|
||||
existing = fake_system(id=7, name="Scrape Pipeline")
|
||||
existing.id = 7
|
||||
existing.name = "Scrape Pipeline"
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.list_systems = AsyncMock(return_value=[existing])
|
||||
svc.create_system = AsyncMock()
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
result = await create_system(project_id=5, name=" scrape pipeline ")
|
||||
assert result["duplicate"] is True
|
||||
assert result["existing_id"] == 7
|
||||
svc.create_system.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_distinct_name_passes_the_gate():
|
||||
other = fake_system(id=7, name="Workers")
|
||||
other.name = "Workers"
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.list_systems = AsyncMock(return_value=[other])
|
||||
svc.create_system = AsyncMock(return_value=fake_system(id=8, name="Exporter"))
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
result = await create_system(project_id=5, name="Exporter")
|
||||
assert result["name"] == "Exporter"
|
||||
# The name gate's own cases (case/whitespace normalisation, exact-over-overlap,
|
||||
# fail-open) moved to tests/test_services_systems.py with the logic itself —
|
||||
# services/systems.assess_system_name, so both doors share one answer (#2482).
|
||||
# What stays the TOOL's job — rendering a duplicate, applying an exact area,
|
||||
# offering an overlap — is covered at the top of this file.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -44,7 +44,8 @@ def test_service_signatures_require_user_id():
|
||||
"create_rulebook", "list_rulebooks", "get_rulebook",
|
||||
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
|
||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||
"create_rule", "create_project_rule",
|
||||
"create_rule", "create_project_rule", "rule_detail",
|
||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||
"list_rules", "list_always_on_rules",
|
||||
"get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||
@@ -117,5 +118,7 @@ def test_rule_and_subscription_handlers_callable():
|
||||
for name in (
|
||||
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_project_rules",
|
||||
# The typed edges — both doors carry them (rule 33).
|
||||
"relate_rules", "unrelate_rules",
|
||||
):
|
||||
assert callable(getattr(rb_routes, name))
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""The canonical-slug matcher — the line between what maps mechanically and
|
||||
what a human is asked to confirm (milestone 307, note 3026).
|
||||
|
||||
These cases are the real drift found across the author's own instance: one area
|
||||
carrying three spellings, and two areas that LOOK alike and are not the same.
|
||||
Getting the boundary wrong in either direction is a silent failure — a missed
|
||||
mapping nobody thinks to make again, or a cross-project record surfacing in the
|
||||
wrong project.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.services.canonical_systems import canonical_slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["CI & Release", "CI and Release", "CI & release",
|
||||
" ci and release "])
|
||||
def test_spelling_variants_of_one_area_collapse_to_one_key(name):
|
||||
"""Case, spacing, punctuation and "&" vs "and" are not real differences.
|
||||
All three of the first spellings were live in different projects at once."""
|
||||
assert canonical_slug(name) == "ci-and-release"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("Auth & Access", "auth-and-access"),
|
||||
("Data Model & Storage", "data-model-and-storage"),
|
||||
("UI & Design", "ui-and-design"),
|
||||
("Background Jobs", "background-jobs"),
|
||||
("Observability", "observability"),
|
||||
])
|
||||
def test_the_seeded_vocabulary_slugs_match_the_migration(name, expected):
|
||||
"""Migration 0087 writes these slugs literally. If the function and the
|
||||
migration disagree, every seeded entry becomes unreachable by exact match
|
||||
and every mapping silently degrades to a proposal."""
|
||||
assert canonical_slug(name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b", [
|
||||
("CI & Release", "CI & runners"),
|
||||
("Auth & Access", "Auth & Accounts"),
|
||||
("UI & Design", "Frontend (Vue app)"),
|
||||
("UI & Design", "Web Shell and Theme"),
|
||||
])
|
||||
def test_genuinely_different_names_do_not_collapse(a, b):
|
||||
"""These pairs may or may not be the same area — that is a judgment call,
|
||||
so they must NOT map automatically. They reach the operator as proposals."""
|
||||
assert canonical_slug(a) != canonical_slug(b)
|
||||
|
||||
|
||||
def test_a_nameless_system_yields_no_key():
|
||||
"""An empty slug is the one value callers must special-case: two unnameable
|
||||
Systems must not map onto each other. Both propose_mappings and find_by_name
|
||||
bail on a falsy slug for this reason."""
|
||||
assert canonical_slug("") == ""
|
||||
assert canonical_slug(" ") == ""
|
||||
assert canonical_slug("---") == ""
|
||||
|
||||
|
||||
def test_a_punctuation_separator_is_not_read_as_the_word_and():
|
||||
""""CI/Release" is a real spelling and it does NOT collapse onto
|
||||
"CI & Release" — only "&" carries that meaning. It still reaches the
|
||||
operator through the overlap path, where both words match; what it must not
|
||||
do is map itself silently."""
|
||||
assert canonical_slug("CI/Release") == "ci-release"
|
||||
assert canonical_slug("CI/Release") != canonical_slug("CI & Release")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The document a rule is EMBEDDED as (milestone 307 step 4, note 3026).
|
||||
|
||||
This shape is measured, not chosen. Note 2485 probed the live corpus and found
|
||||
the snippet was the only discriminative record in it — a 0.153 top-to-second
|
||||
gap against 0.010-0.023 for everything else — and that the cause was its shape:
|
||||
purpose stated twice in a short, single-topic document. These cases pin that
|
||||
recipe onto rules, and pin the exclusion that matters more than any of it.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.services.embeddings import chunk_document, rule_document
|
||||
|
||||
|
||||
def test_the_trigger_appears_twice_which_is_what_makes_a_vector_sharp():
|
||||
"""Repetition of purpose + brevity is the measured cause of the snippet's
|
||||
separation. The rule document reproduces it exactly: the trigger in the
|
||||
title, and again as the body's first line."""
|
||||
title, body = rule_document(
|
||||
"Release — never without explicit request",
|
||||
"Never cut a release without the operator explicitly asking.",
|
||||
"before cutting any release",
|
||||
)
|
||||
assert title == "Release — never without explicit request — before cutting any release"
|
||||
assert body.startswith("When to apply: before cutting any release")
|
||||
assert "Never cut a release" in body
|
||||
|
||||
|
||||
def test_why_is_never_embedded():
|
||||
"""The exclusion this whole design turns on. `why` is dated incident
|
||||
narrative — rule 46's runs to 4,300 characters — and long multi-topic prose
|
||||
is what made sixteen dev-logs mutually indistinguishable: the average lands
|
||||
on a centroid they all share. Adding it would not give the vector more to
|
||||
work with; it would give every rule the SAME thing to work with.
|
||||
|
||||
rule_document takes no `why` parameter at all, which is the strongest form
|
||||
of this guarantee: it cannot be passed in by a caller who means well.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
assert "why" not in inspect.signature(rule_document).parameters
|
||||
|
||||
|
||||
def test_a_rule_with_no_trigger_still_embeds_just_less_sharply():
|
||||
"""Every rule written before milestone 307 has no trigger. Degrading to
|
||||
title + statement keeps them findable; it does NOT pad the document with
|
||||
whatever text is lying around, which would be the tempting fix and the
|
||||
wrong one."""
|
||||
title, body = rule_document("dev is home", "Work directly on dev.", "")
|
||||
assert title == "dev is home"
|
||||
assert body == "Work directly on dev."
|
||||
assert "When to apply" not in body
|
||||
|
||||
|
||||
def test_an_empty_rule_yields_no_document_and_therefore_no_vector():
|
||||
"""Callers gate on falsiness to skip embedding — the same contract
|
||||
chunk_document has, so an emptied record stops being findable by its old
|
||||
content rather than keeping a stale vector."""
|
||||
assert rule_document("", "", "") == (None, None)
|
||||
assert chunk_document(*rule_document("", "", "")) == []
|
||||
|
||||
|
||||
def test_a_long_rule_chunks_and_every_chunk_carries_the_trigger():
|
||||
"""Rule 46's statement is ~3,600 characters across two headed sections.
|
||||
The existing chunker splits it at headings and prefixes each chunk with the
|
||||
title — which now CONTAINS the trigger, so each half stays anchored to what
|
||||
the rule is for. This is why splitting a merged rule costs nothing at
|
||||
retrieval time."""
|
||||
statement = (
|
||||
"## The tags\n\n" + ("Four tags, four jobs. " * 60)
|
||||
+ "\n\n## The artifact's own version\n\n" + ("Two version values. " * 60)
|
||||
)
|
||||
title, body = rule_document("Versioning", statement, "when cutting a release")
|
||||
chunks = chunk_document(title, body)
|
||||
assert len(chunks) > 1
|
||||
assert all("when cutting a release" in chunk for chunk in chunks)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trigger,expected_title", [
|
||||
("before any git push", "dev is home — before any git push"),
|
||||
(" before any git push ", "dev is home — before any git push"),
|
||||
])
|
||||
def test_the_trigger_is_trimmed_before_it_reaches_the_vector(trigger, expected_title):
|
||||
title, _ = rule_document("dev is home", "Work on dev.", trigger)
|
||||
assert title == expected_title
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Mirrors the pattern in tests/test_events_service.py.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -197,6 +198,25 @@ def _empty():
|
||||
return r
|
||||
|
||||
|
||||
def _no_edges():
|
||||
"""Silence the three post-query lookups get_applicable_rules now makes.
|
||||
|
||||
They are separate service functions with their own coverage (and the real
|
||||
wiring is proven against Postgres in test_integration_rule_surfacing), so
|
||||
stubbing them here keeps each of these tests about the one projection it
|
||||
was written to check — rather than about the order a mocked session's
|
||||
execute() calls happen to arrive in.
|
||||
"""
|
||||
return (
|
||||
patch("scribe.services.rulebooks.co_surfaced_partners",
|
||||
AsyncMock(return_value=[])),
|
||||
patch("scribe.services.rulebooks.list_rule_relations",
|
||||
AsyncMock(return_value={})),
|
||||
patch("scribe.services.rulebooks.list_rule_systems",
|
||||
AsyncMock(return_value={})),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_returns_shape():
|
||||
"""get_applicable_rules returns the full projection — including the
|
||||
@@ -206,16 +226,20 @@ async def test_get_applicable_rules_returns_shape():
|
||||
sub_result.all.return_value = [(1, "FabledSword family")]
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
# (rule_id, title, statement, topic_id, topic_title, rulebook_id, rulebook_title)
|
||||
(i, f"Rule {i}", f"Statement {i}", 2, "git-workflow", 1, "FabledSword family")
|
||||
# The query selects the ENTITY plus three labels, so rule_brief stays
|
||||
# the one place deciding what a surfaced rule carries (note 3026).
|
||||
(fake_rule(id=i, title=f"Rule {i}", statement=f"Statement {i}", topic_id=2),
|
||||
"git-workflow", 1, "FabledSword family")
|
||||
for i in range(50)
|
||||
]
|
||||
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, rules_q, proj_rules_q
|
||||
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q,
|
||||
# project-areas_q (milestone 307), rules_q, proj_rules_q
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
sub_result, _empty(), _empty(), rules_result, _empty(),
|
||||
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
|
||||
])
|
||||
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls:
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
||||
@@ -244,13 +268,14 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
sub_result.all.return_value = []
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
(i, f"r{i}", "stmt", 2, "topic", 1, "rb") for i in range(51)
|
||||
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
sub_result, _empty(), _empty(), rules_result, _empty(),
|
||||
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
|
||||
])
|
||||
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls:
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
||||
@@ -265,14 +290,17 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
mock_session = make_mock_session()
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = [
|
||||
(100, "Use alembic", "Always run migrations via alembic, never raw SQL."),
|
||||
(101, "PR-bound", "Land schema changes in their own PR."),
|
||||
(fake_rule(id=100, topic_id=None, project_id=3, title="Use alembic",
|
||||
statement="Always run migrations via alembic, never raw SQL."),),
|
||||
(fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound",
|
||||
statement="Land schema changes in their own PR."),),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
_empty(), _empty(), _empty(), _empty(), proj_rules_result,
|
||||
_empty(), _empty(), _empty(), _empty(), _empty(), proj_rules_result,
|
||||
])
|
||||
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls:
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
@@ -298,10 +326,14 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
(22, "design-system", 1, "FabledSword family"),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
_empty(), suppressed_rules_result, suppressed_topics_result, _empty(), _empty(),
|
||||
# sub_q, suppressed_rules_q, suppressed_topics_q, project-areas_q,
|
||||
# rules_q, proj_rules_q
|
||||
_empty(), suppressed_rules_result, suppressed_topics_result,
|
||||
_empty(), _empty(), _empty(),
|
||||
])
|
||||
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls:
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
@@ -311,3 +343,52 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
|
||||
assert len(result["suppressed_topics"]) == 1
|
||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||
|
||||
|
||||
# ── rule_brief + tier (milestone 307) ───────────────────────────────────
|
||||
|
||||
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
||||
"""The shape a SURFACED rule takes, and the reason it exists.
|
||||
|
||||
There were three hand-written copies of this dict and they had already
|
||||
diverged — none carried the timestamps the model has always held, which is
|
||||
why a rule written before the capability it duplicates was
|
||||
indistinguishable at read time from one still doing work (note 3026).
|
||||
"""
|
||||
from scribe.services.rulebooks import rule_brief
|
||||
|
||||
out = rule_brief(fake_rule(
|
||||
when_to_apply="before any git push",
|
||||
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
|
||||
))
|
||||
assert out["when_to_apply"] == "before any git push"
|
||||
assert out["tier"] == "always_on"
|
||||
# A DATE, not a stamp: the question is "how old is this", and a full ISO
|
||||
# string across the always-on set is ~2k characters of payload.
|
||||
assert out["updated_at"] == "2026-06-01"
|
||||
# The depth stays with get_rule — putting it in every listing is the bloat
|
||||
# this milestone is about.
|
||||
assert "why" not in out and "how_to_apply" not in out
|
||||
|
||||
|
||||
def test_rule_brief_omits_keys_a_rule_has_no_value_for():
|
||||
"""#2483: a null key reads as a capability the record has and isn't using,
|
||||
which is a different claim from not having one."""
|
||||
from scribe.services.rulebooks import rule_brief
|
||||
|
||||
out = rule_brief(fake_rule())
|
||||
assert "when_to_apply" not in out
|
||||
assert "arose_from_id" not in out
|
||||
|
||||
|
||||
def test_an_unknown_tier_falls_back_to_binding():
|
||||
"""The asymmetry that decides the direction: a rule that preloads when it
|
||||
needn't costs context; a rule that quietly stops preloading costs the
|
||||
behaviour it was written for. So a typo binds."""
|
||||
from scribe.services.rulebooks import _valid_tier
|
||||
|
||||
assert _valid_tier("conditional") == "conditional"
|
||||
assert _valid_tier("always_on") == "always_on"
|
||||
assert _valid_tier("Conditional") == "always_on"
|
||||
assert _valid_tier("") == "always_on"
|
||||
assert _valid_tier("occasionally") == "always_on"
|
||||
|
||||
@@ -59,3 +59,70 @@ async def test_count_open_issues_denied_returns_zero():
|
||||
from scribe.services.systems import count_open_issues
|
||||
result = await count_open_issues(user_id=1, project_id=5)
|
||||
assert result == 0
|
||||
|
||||
|
||||
# ── assess_system_name — the one gate both doors call (milestone 307) ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assess_matches_an_existing_system_on_case_and_spacing():
|
||||
"""The local gate compares the operator's own spelling: case and runs of
|
||||
whitespace are not a different System. Anything stronger belongs to the
|
||||
CANONICAL slug, which is a different question (is this the same AREA as
|
||||
another project's System) with a different answer."""
|
||||
existing = MagicMock()
|
||||
existing.id, existing.name = 7, "Scrape Pipeline"
|
||||
with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[existing])):
|
||||
from scribe.services.systems import assess_system_name
|
||||
out = await assess_system_name(1, 5, " scrape pipeline ")
|
||||
assert out["duplicate"] == {"id": 7, "name": "Scrape Pipeline"}
|
||||
# A duplicate short-circuits: there is nothing to file when nothing is created.
|
||||
assert out["canonical"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assess_prefers_an_exact_area_over_a_merely_similar_one():
|
||||
from scribe.services.systems import assess_system_name
|
||||
exact = MagicMock()
|
||||
exact.id, exact.name = 3, "CI & Release"
|
||||
with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.systems.canonical_systems_svc") as canon:
|
||||
canon.find_by_name = AsyncMock(return_value=exact)
|
||||
canon.best_overlap = AsyncMock()
|
||||
out = await assess_system_name(1, 5, "CI and Release")
|
||||
assert out["canonical"] == {"id": 3, "name": "CI & Release", "basis": "exact"}
|
||||
# An exact hit is the answer — no need to go looking for a lesser one.
|
||||
canon.best_overlap.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assess_falls_back_to_overlap_and_never_invents_a_match():
|
||||
from scribe.services.systems import assess_system_name
|
||||
near = {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33}
|
||||
with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.systems.canonical_systems_svc") as canon:
|
||||
canon.find_by_name = AsyncMock(return_value=None)
|
||||
canon.best_overlap = AsyncMock(return_value=near)
|
||||
assert (await assess_system_name(1, 5, "CI & runners"))["canonical"] == near
|
||||
canon.best_overlap = AsyncMock(return_value=None)
|
||||
# Nothing resembles it: a project-specific area, and silence is the
|
||||
# right answer — unmapped is a valid resting state.
|
||||
assert (await assess_system_name(1, 5, "Soundboard"))["canonical"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assess_fails_open_so_a_naming_aid_cannot_block_a_create():
|
||||
"""Both arms degrade to "no opinion" rather than raising. A create must
|
||||
never fail because the catalog was unreachable."""
|
||||
from scribe.services.systems import assess_system_name
|
||||
with patch("scribe.services.systems.list_systems", AsyncMock(side_effect=RuntimeError("db down"))):
|
||||
assert await assess_system_name(1, 5, "Reader") == {"duplicate": None, "canonical": None}
|
||||
with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.systems.canonical_systems_svc") as canon:
|
||||
canon.find_by_name = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
assert (await assess_system_name(1, 5, "Reader"))["canonical"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assess_says_nothing_about_a_nameless_system():
|
||||
from scribe.services.systems import assess_system_name
|
||||
assert await assess_system_name(1, 5, " ") == {"duplicate": None, "canonical": None}
|
||||
|
||||
@@ -1435,3 +1435,20 @@ async def test_the_write_time_divergence_check_is_named_in_band():
|
||||
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")])
|
||||
check.assert_not_awaited()
|
||||
assert out["divergence"] == []
|
||||
|
||||
|
||||
def test_hook_keeps_the_rule_channel_apart_from_the_other_three():
|
||||
"""Milestone 307's arm, pinned the way #2708's was.
|
||||
|
||||
Standing rules dedup on their OWN file and their OWN query parameter. One
|
||||
shared channel is the bug #2708 already fixed once: a hint of one class
|
||||
silencing a different class that had never been shown. A rule named early
|
||||
must not be re-offered on every later write, and must not silence — or be
|
||||
silenced by — a snippet suggestion.
|
||||
"""
|
||||
src = HOOK.read_text()
|
||||
assert ".rules.ids" in src # its own state file
|
||||
assert "exclude_rule_ids=" in src # its own query channel
|
||||
assert "(.rule_ids // [])[]?" in src # its own write-back
|
||||
# And it rides the same request as the rest, not a second round trip.
|
||||
assert "${rule_exclude_q}" in src
|
||||
|
||||
Reference in New Issue
Block a user