Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e980ee4b0 | |||
| 7861607fb8 | |||
| b5870d4694 | |||
| c5469214e3 | |||
| 43a860c3ac | |||
| 658348f208 | |||
| c810d63bee | |||
| fd20b67b22 | |||
| 7031e36670 |
@@ -4,7 +4,7 @@ A self-hosted second brain and project management application with integrated LL
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app.
|
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -36,7 +36,6 @@ Open `http://localhost:5000`. The first user to register becomes admin. Go to **
|
|||||||
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
||||||
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
||||||
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
||||||
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""rulebook always_on flag
|
||||||
|
|
||||||
|
Revision ID: 0058
|
||||||
|
Revises: 0057
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks
|
||||||
|
flagged always_on are loaded at session start by the new
|
||||||
|
`list_always_on_rules` MCP tool — they apply regardless of which project
|
||||||
|
(if any) is in scope. Seeds the FabledSword family rulebook to always_on
|
||||||
|
because that's the cross-project standards rulebook by design.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0058"
|
||||||
|
down_revision = "0057"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"rulebooks",
|
||||||
|
sa.Column(
|
||||||
|
"always_on",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("rulebooks", "always_on")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""project-scoped rules
|
||||||
|
|
||||||
|
Revision ID: 0059
|
||||||
|
Revises: 0058
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Rules can now belong to either a rulebook topic (cross-project standard) or
|
||||||
|
a single project (project-scoped). Adds `rules.project_id`, makes `topic_id`
|
||||||
|
nullable, and adds a CHECK constraint enforcing exactly-one. The previous
|
||||||
|
unique constraint on (topic_id, title) still applies because PostgreSQL
|
||||||
|
treats NULL as distinct — two project-scoped rules with the same title and
|
||||||
|
NULL topic_id remain unique.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0059"
|
||||||
|
down_revision = "0058"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"rules",
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column("rules", "topic_id", nullable=True)
|
||||||
|
op.create_index("ix_rules_project_id", "rules", ["project_id"])
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_rule_topic_xor_project",
|
||||||
|
"rules",
|
||||||
|
"(topic_id IS NULL) <> (project_id IS NULL)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check")
|
||||||
|
op.drop_index("ix_rules_project_id", table_name="rules")
|
||||||
|
# Any rule with NULL topic_id will block re-tightening. Operator must
|
||||||
|
# migrate or delete project-scoped rules before downgrading.
|
||||||
|
op.alter_column("rules", "topic_id", nullable=False)
|
||||||
|
op.drop_column("rules", "project_id")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""project rule + topic suppressions
|
||||||
|
|
||||||
|
Revision ID: 0060
|
||||||
|
Revises: 0059
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Lets a project mute specific rules or whole topics from rulebooks it
|
||||||
|
subscribes to, without unsubscribing the rulebook. Two pure many-to-many
|
||||||
|
association tables; FKs CASCADE so removing a project / rule / topic
|
||||||
|
cleans the suppression rows automatically.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0060"
|
||||||
|
down_revision = "0059"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"project_rule_suppressions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rule_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rules.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"project_topic_suppressions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"topic_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("project_topic_suppressions")
|
||||||
|
op.drop_table("project_rule_suppressions")
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Android Companion App
|
|
||||||
|
|
||||||
The Android companion app lives in a separate repository at `/home/bvandeusen/Nextcloud/Projects/fabled_app`.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- Flutter + Dart
|
|
||||||
- Riverpod (state management)
|
|
||||||
- GoRouter (navigation)
|
|
||||||
- Dio (HTTP client)
|
|
||||||
- PersistCookieJar (session persistence)
|
|
||||||
- SSE streaming via `fetch` + `ReadableStream` bridge
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/
|
|
||||||
app.dart # GoRouter + _Shell + _QuickCaptureBar
|
|
||||||
core/constants.dart # Routes.*
|
|
||||||
data/
|
|
||||||
models/ # note.dart, task.dart, project.dart
|
|
||||||
api/ # notes_api.dart, tasks_api.dart, projects_api.dart
|
|
||||||
repositories/ # notes, tasks, projects repositories
|
|
||||||
providers/
|
|
||||||
api_client_provider.dart # all API + repository providers
|
|
||||||
notes_provider.dart # NotesNotifier
|
|
||||||
tasks_provider.dart # TasksNotifier
|
|
||||||
projects_provider.dart # ProjectsNotifier
|
|
||||||
screens/
|
|
||||||
notes/note_edit_screen.dart # chip tag input + ProjectSelector
|
|
||||||
tasks/task_edit_screen.dart # ProjectSelector
|
|
||||||
projects/project_list_screen.dart
|
|
||||||
widgets/
|
|
||||||
project_selector.dart # reusable DropdownButtonFormField
|
|
||||||
```
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
4-tab shell (Notes · Tasks · Projects · Chat):
|
|
||||||
- Phone: bottom `NavigationBar`
|
|
||||||
- Tablet/landscape: `NavigationRail`
|
|
||||||
|
|
||||||
Quick Capture bar persists across all tabs. Settings accessible from top-right icon.
|
|
||||||
|
|
||||||
## Feature Status
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Notes CRUD | ✅ | Tags chip input; project selector in editor |
|
|
||||||
| Tasks CRUD | ✅ | Project selector in editor |
|
|
||||||
| Projects list | ✅ | Active/archived sections; long-press status change; create dialog |
|
|
||||||
| Chat + SSE | ✅ | Full streaming |
|
|
||||||
| Quick Capture | ✅ | Offline queue with retry |
|
|
||||||
| Tags | ✅ | Chip input in NoteEditScreen; typed as `List<String>` |
|
|
||||||
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
|
|
||||||
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
|
|
||||||
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
|
|
||||||
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
|
|
||||||
|
|
||||||
## API Compatibility Notes
|
|
||||||
|
|
||||||
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
|
|
||||||
- `POST /api/projects` returns the project dict directly (201).
|
|
||||||
- `PATCH /api/projects/:id` returns the updated project dict.
|
|
||||||
- Task body field is `body` (not `description`) — the app maps `description` → `body` on serialize.
|
|
||||||
|
|
||||||
## Self-Update
|
|
||||||
|
|
||||||
The app supports self-update via the Forgejo release API (`update_provider.dart`). It checks the latest release tag and prompts the user to download and install a new APK when one is available.
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
Builds are triggered from the Forgejo Actions pipeline in the `fabled_app` repository. The APK is attached to the release as a downloadable artifact.
|
|
||||||
@@ -568,17 +568,6 @@ Items deliberately not addressed in this round; revisit when a real need surface
|
|||||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||||
|
|
||||||
### Flutter app port — shipped 2026-04-28
|
|
||||||
|
|
||||||
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
|
|
||||||
|
|
||||||
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
|
|
||||||
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
|
|
||||||
|
|
||||||
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
|
|
||||||
|
|
||||||
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
|
|
||||||
|
|
||||||
### Open threads
|
### Open threads
|
||||||
|
|
||||||
*New threads will accumulate here as gaps surface in real use.*
|
*New threads will accumulate here as gaps surface in real use.*
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ Settings are tabbed:
|
|||||||
|
|
||||||
- Email integration (read/send via IMAP/SMTP tools in chat)
|
- Email integration (read/send via IMAP/SMTP tools in chat)
|
||||||
- Session invalidation on user deletion
|
- Session invalidation on user deletion
|
||||||
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
|
|
||||||
- Flutter milestone support in project view
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Rulebook {
|
|||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
always_on: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -21,7 +22,8 @@ export interface RulebookTopic {
|
|||||||
|
|
||||||
export interface Rule {
|
export interface Rule {
|
||||||
id: number;
|
id: number;
|
||||||
topic_id: number;
|
topic_id: number | null;
|
||||||
|
project_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
why: string;
|
why: string;
|
||||||
@@ -35,7 +37,7 @@ export interface RuleHeader {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number;
|
topic_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicableRules {
|
export interface ApplicableRules {
|
||||||
@@ -43,7 +45,28 @@ export interface ApplicableRules {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
|
topic_id: number;
|
||||||
topic_title: string;
|
topic_title: string;
|
||||||
|
rulebook_id: number;
|
||||||
|
rulebook_title: string;
|
||||||
|
}[];
|
||||||
|
project_rules: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
statement: string;
|
||||||
|
}[];
|
||||||
|
suppressed_rules: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
topic_id: number;
|
||||||
|
topic_title: string;
|
||||||
|
rulebook_id: number;
|
||||||
|
rulebook_title: string;
|
||||||
|
}[];
|
||||||
|
suppressed_topics: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
rulebook_id: number;
|
||||||
rulebook_title: string;
|
rulebook_title: string;
|
||||||
}[];
|
}[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
@@ -65,7 +88,7 @@ export async function createRulebook(data: { title: string; description?: string
|
|||||||
return apiPost("/api/rulebooks", data);
|
return apiPost("/api/rulebooks", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
|
||||||
return apiPatch(`/api/rulebooks/${id}`, data);
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,3 +156,28 @@ export async function unsubscribeProject(projectId: number, rulebookId: number):
|
|||||||
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
||||||
return apiGet(`/api/projects/${projectId}/rules`);
|
return apiGet(`/api/projects/${projectId}/rules`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createProjectRule(
|
||||||
|
projectId: number,
|
||||||
|
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||||
|
): Promise<Rule> {
|
||||||
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Suppressions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||||
|
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
|
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { ref, onMounted, watch } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||||
listRulebooks, getRule,
|
listRulebooks, getRule, createProjectRule, deleteRule,
|
||||||
|
suppressRuleForProject, unsuppressRuleForProject,
|
||||||
|
suppressTopicForProject, unsuppressTopicForProject,
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -16,6 +18,9 @@ const expandedRuleIds = ref<Set<number>>(new Set());
|
|||||||
|
|
||||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||||
|
|
||||||
|
const showProjectRuleForm = ref(false);
|
||||||
|
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||||
}
|
}
|
||||||
@@ -59,18 +64,74 @@ function openInRulesView(rulebookId: number, ruleId?: number) {
|
|||||||
router.push({ path: "/rules", query });
|
router.push({ path: "/rules", query });
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) {
|
interface TopicGroup {
|
||||||
const grouped: Record<string, Record<string, ApplicableRules["rules"]>> = {};
|
topic_id: number;
|
||||||
|
topic_title: string;
|
||||||
|
rules: ApplicableRules["rules"];
|
||||||
|
}
|
||||||
|
interface RulebookGroup {
|
||||||
|
rulebook_id: number;
|
||||||
|
rulebook_title: string;
|
||||||
|
topics: TopicGroup[];
|
||||||
|
}
|
||||||
|
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||||
|
const byRulebook = new Map<number, RulebookGroup>();
|
||||||
for (const r of rules) {
|
for (const r of rules) {
|
||||||
if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {};
|
let rb = byRulebook.get(r.rulebook_id);
|
||||||
if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = [];
|
if (!rb) {
|
||||||
grouped[r.rulebook_title][r.topic_title].push(r);
|
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);
|
||||||
|
if (!topic) {
|
||||||
|
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
||||||
|
rb.topics.push(topic);
|
||||||
|
}
|
||||||
|
topic.rules.push(r);
|
||||||
}
|
}
|
||||||
return grouped;
|
return Array.from(byRulebook.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function rulebookIdForTitle(title: string): number | undefined {
|
async function submitProjectRule() {
|
||||||
return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
|
const statement = newProjectRule.value.statement.trim();
|
||||||
|
if (!statement) return;
|
||||||
|
await createProjectRule(props.projectId, {
|
||||||
|
statement,
|
||||||
|
title: newProjectRule.value.title.trim() || undefined,
|
||||||
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
|
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||||
|
});
|
||||||
|
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||||||
|
showProjectRuleForm.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProjectRule(ruleId: number) {
|
||||||
|
if (!confirm("Delete this project rule? It will move to the trash.")) return;
|
||||||
|
await deleteRule(ruleId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSuppressed = ref(false);
|
||||||
|
|
||||||
|
async function suppressRule(ruleId: number) {
|
||||||
|
await suppressRuleForProject(props.projectId, ruleId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsuppressRule(ruleId: number) {
|
||||||
|
await unsuppressRuleForProject(props.projectId, ruleId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suppressTopic(topicId: number) {
|
||||||
|
await suppressTopicForProject(props.projectId, topicId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsuppressTopic(topicId: number) {
|
||||||
|
await unsuppressTopicForProject(props.projectId, topicId);
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -111,6 +172,69 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="project-rules">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>Project rules</h3>
|
||||||
|
<button
|
||||||
|
v-if="!showProjectRuleForm"
|
||||||
|
class="add"
|
||||||
|
@click="showProjectRuleForm = true"
|
||||||
|
>
|
||||||
|
+ New project rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
|
||||||
|
<input
|
||||||
|
v-model="newProjectRule.title"
|
||||||
|
placeholder="Title (optional — derived from statement if blank)"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.statement"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.why"
|
||||||
|
placeholder="Why (optional) — the rationale"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.how_to_apply"
|
||||||
|
placeholder="How to apply (optional) — when / where it kicks in"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
|
||||||
|
<li v-for="r in applicable.project_rules" :key="r.id" class="rule">
|
||||||
|
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
||||||
|
<span class="rule-title">{{ r.title }}</span>
|
||||||
|
<span class="rule-statement">{{ r.statement }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||||
|
<div v-if="ruleDetails[r.id].why">
|
||||||
|
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||||||
|
</div>
|
||||||
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
|
</div>
|
||||||
|
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-else-if="!showProjectRuleForm"
|
||||||
|
class="empty"
|
||||||
|
>
|
||||||
|
No project-only rules yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="applicable">
|
<section class="applicable">
|
||||||
<h3>Applicable rules</h3>
|
<h3>Applicable rules</h3>
|
||||||
<p v-if="applicable.rules.length === 0" class="empty">
|
<p v-if="applicable.rules.length === 0" class="empty">
|
||||||
@@ -118,18 +242,32 @@ watch(() => props.projectId, load);
|
|||||||
<a @click="router.push('/rules')">Rulebooks</a>.
|
<a @click="router.push('/rules')">Rulebooks</a>.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-for="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
|
v-for="rb in groupByRulebookAndTopic(applicable.rules)"
|
||||||
:key="rbTitle"
|
:key="rb.rulebook_id"
|
||||||
class="rb-group"
|
class="rb-group"
|
||||||
>
|
>
|
||||||
<h4>{{ rbTitle }}</h4>
|
<h4>{{ rb.rulebook_title }}</h4>
|
||||||
<div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
|
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
|
||||||
<h5>{{ topicTitle }}</h5>
|
<h5>
|
||||||
|
<span>{{ topic.topic_title }}</span>
|
||||||
|
<button
|
||||||
|
class="skip-btn"
|
||||||
|
:title="`Skip the entire ${topic.topic_title} topic for this project`"
|
||||||
|
@click="suppressTopic(topic.topic_id)"
|
||||||
|
>× skip topic</button>
|
||||||
|
</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="r in rules" :key="r.id" class="rule">
|
<li v-for="r in topic.rules" :key="r.id" class="rule">
|
||||||
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
<div class="rule-head">
|
||||||
<span class="rule-title">{{ r.title }}</span>
|
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
|
||||||
<span class="rule-statement">{{ r.statement }}</span>
|
<span class="rule-title">{{ r.title }}</span>
|
||||||
|
<span class="rule-statement">{{ r.statement }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="skip-btn"
|
||||||
|
title="Skip this rule for this project"
|
||||||
|
@click.stop="suppressRule(r.id)"
|
||||||
|
>× skip</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||||
<div v-if="ruleDetails[r.id].why">
|
<div v-if="ruleDetails[r.id].why">
|
||||||
@@ -140,7 +278,7 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="edit-link"
|
class="edit-link"
|
||||||
@click="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, r.id)"
|
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||||
>
|
>
|
||||||
Edit in Rulebook →
|
Edit in Rulebook →
|
||||||
</button>
|
</button>
|
||||||
@@ -153,6 +291,32 @@ watch(() => props.projectId, load);
|
|||||||
Truncated at 50 rules — there are more applicable rules.
|
Truncated at 50 rules — there are more applicable rules.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
|
||||||
|
class="suppressed"
|
||||||
|
>
|
||||||
|
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
|
||||||
|
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
|
||||||
|
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="showSuppressed" class="suppressed-body">
|
||||||
|
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
|
||||||
|
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
|
||||||
|
<span class="suppressed-kind">topic</span>
|
||||||
|
<span class="suppressed-path">{{ t.rulebook_title }} → {{ t.title }}</span>
|
||||||
|
<button class="reenable-btn" @click="unsuppressTopic(t.id)">↻ re-enable</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
|
||||||
|
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
|
||||||
|
<span class="suppressed-kind">rule</span>
|
||||||
|
<span class="suppressed-path">{{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }}</span>
|
||||||
|
<button class="reenable-btn" @click="unsuppressRule(r.id)">↻ re-enable</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -208,4 +372,67 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
}
|
}
|
||||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||||
.empty a { cursor: pointer; text-decoration: underline; }
|
.empty a { cursor: pointer; text-decoration: underline; }
|
||||||
|
.project-rules { margin-top: 1.5rem; }
|
||||||
|
.section-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.new-rule-form {
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
padding: 0.75rem; margin: 0.5rem 0;
|
||||||
|
background: var(--color-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.new-rule-form input, .new-rule-form textarea {
|
||||||
|
background: var(--color-surface, #18181b); color: inherit;
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
padding: 0.5rem; font: inherit; resize: vertical;
|
||||||
|
}
|
||||||
|
.rule-list { margin-top: 0.5rem; }
|
||||||
|
.delete-link {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
||||||
|
}
|
||||||
|
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||||
|
.topic-group h5 {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.rule-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.rule-head-text { flex: 1; cursor: pointer; }
|
||||||
|
.skip-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-muted, #888); font-size: 0.75rem;
|
||||||
|
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.topic-group h5:hover .skip-btn,
|
||||||
|
.rule:hover .skip-btn,
|
||||||
|
.skip-btn:focus { opacity: 1; }
|
||||||
|
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||||
|
/* Suppressed section */
|
||||||
|
.suppressed { margin-top: 1.5rem; }
|
||||||
|
.suppressed-toggle {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
|
||||||
|
}
|
||||||
|
.suppressed-toggle:hover { opacity: 1; }
|
||||||
|
.suppressed-toggle .caret { font-size: 0.7em; }
|
||||||
|
.suppressed-body { margin-top: 0.5rem; }
|
||||||
|
.suppressed-list { padding-left: 0; }
|
||||||
|
.suppressed-list li {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0; opacity: 0.75;
|
||||||
|
}
|
||||||
|
.suppressed-kind {
|
||||||
|
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||||
|
background: var(--color-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e);
|
||||||
|
}
|
||||||
|
.suppressed-path { flex: 1; }
|
||||||
|
.reenable-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.reenable-btn:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,10 @@ const store = useRulebooksStore();
|
|||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
const newTitle = ref("");
|
const newTitle = ref("");
|
||||||
|
|
||||||
|
const currentRulebook = computed(() =>
|
||||||
|
store.rulebooks.find((rb) => rb.id === props.rulebookId),
|
||||||
|
);
|
||||||
|
|
||||||
interface ProjectLite { id: number; title: string }
|
interface ProjectLite { id: number; title: string }
|
||||||
const projects = ref<ProjectLite[]>([]);
|
const projects = ref<ProjectLite[]>([]);
|
||||||
// Map<project_id, Set<rulebook_id>>
|
// Map<project_id, Set<rulebook_id>>
|
||||||
@@ -68,7 +72,17 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
<header><h2>Topics</h2></header>
|
<header>
|
||||||
|
<h2>Topics</h2>
|
||||||
|
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="currentRulebook.always_on"
|
||||||
|
@change="store.toggleAlwaysOn(currentRulebook.id)"
|
||||||
|
/>
|
||||||
|
<span>Always on</span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="t in topics"
|
v-for="t in topics"
|
||||||
@@ -109,7 +123,14 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
|
.always-on-toggle {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.always-on-toggle input { cursor: pointer; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async function submitNew() {
|
|||||||
@click="emit('select', rb.id)"
|
@click="emit('select', rb.id)"
|
||||||
>
|
>
|
||||||
<span class="title">{{ rb.title }}</span>
|
<span class="title">{{ rb.title }}</span>
|
||||||
|
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="new-rulebook">
|
<div class="new-rulebook">
|
||||||
@@ -50,9 +51,19 @@ async function submitNew() {
|
|||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
|
.always-on-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-accent, rgba(91,74,138,0.25));
|
||||||
|
color: var(--color-accent-fg, inherit);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.new-rulebook { margin-top: 1rem; }
|
.new-rulebook { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -54,13 +54,19 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
|
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
|
||||||
const rb = await api.updateRulebook(id, data);
|
const rb = await api.updateRulebook(id, data);
|
||||||
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
||||||
if (idx >= 0) rulebooks.value[idx] = rb;
|
if (idx >= 0) rulebooks.value[idx] = rb;
|
||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAlwaysOn(id: number) {
|
||||||
|
const current = rulebooks.value.find((r) => r.id === id);
|
||||||
|
if (!current) return;
|
||||||
|
return updateRulebook(id, { always_on: !current.always_on });
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRulebook(id: number) {
|
async function deleteRulebook(id: number) {
|
||||||
await api.deleteRulebook(id);
|
await api.deleteRulebook(id);
|
||||||
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
||||||
@@ -121,7 +127,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule,
|
createRule, updateRule, deleteRule,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,20 +39,39 @@ Mechanics:
|
|||||||
"not set".
|
"not set".
|
||||||
|
|
||||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||||
an actionable statement plus optional Why and How-to-apply context. When you
|
an actionable statement plus optional Why and How-to-apply context. At the
|
||||||
start work on a project, get_project(id) returns applicable_rules (the rules
|
start of any session that touches Scribe, call list_always_on_rules() to
|
||||||
from rulebooks the project subscribes to) and subscribed_rulebooks. Consult
|
load the standing rules — treat them as binding. When you also have a project
|
||||||
these before making decisions about workflow, conventions, or scope. Full
|
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
|
||||||
text (Why / How-to-apply) is available via get_rule(id). You may create new
|
project subscribes to) and subscribed_rulebooks; consult those too. Full text
|
||||||
rules via create_rule when you notice a pattern worth codifying — coordinate
|
(Why / How-to-apply) is available via get_rule(id).
|
||||||
with the operator on whether it belongs in an existing rulebook+topic or a
|
|
||||||
new one.
|
|
||||||
|
|
||||||
Plans are tasks with kind=plan. Begin a plan with start_planning(project_id,
|
Engineering and workflow rules live in Scribe. When you notice a pattern
|
||||||
title) — that seeds a plan template and returns the project's applicable_rules.
|
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
|
||||||
Maintain the plan with the normal task tools (update_task for the body,
|
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
|
||||||
add_task_log for progress); its work-logs are the build record. Build plans in
|
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
|
||||||
Scribe via start_planning, not in local .md files.
|
— those stores are reserved for facts about the user (preferences, role,
|
||||||
|
communication style) and codebase onboarding pointers, respectively. Before
|
||||||
|
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||||
|
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||||
|
in a project, an existing rulebook+topic, or a new rulebook.
|
||||||
|
|
||||||
|
When you are working on a specific project, call enter_project(project_id)
|
||||||
|
ONCE at session start (or whenever the active project changes). It returns the
|
||||||
|
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||||
|
summary, open tasks, and recent notes — everything you need to know the lay of
|
||||||
|
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||||
|
search separately when enter_project already composes them.
|
||||||
|
|
||||||
|
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||||
|
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||||
|
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||||
|
seeds the plan body, returns the project's applicable_rules, and gives you the
|
||||||
|
task id you'll write into. If a skill or habit tells you to save a plan or spec
|
||||||
|
to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is
|
||||||
|
superseded here: put the spec/plan content in the kind=plan task's body via
|
||||||
|
update_task, and record progress with add_task_log. Local .md files are not
|
||||||
|
the record — the task is.
|
||||||
|
|
||||||
Deletes are recoverable: every delete_* tool moves the entity (and its
|
Deletes are recoverable: every delete_* tool moves the entity (and its
|
||||||
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from fabledassistant.mcp._context import current_user_id
|
||||||
from fabledassistant.services import milestones as milestones_svc
|
from fabledassistant.services import milestones as milestones_svc
|
||||||
|
from fabledassistant.services import notes as notes_svc
|
||||||
from fabledassistant.services import projects as projects_svc
|
from fabledassistant.services import projects as projects_svc
|
||||||
from fabledassistant.services import rulebooks as rulebooks_svc
|
from fabledassistant.services import rulebooks as rulebooks_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from fabledassistant.services import trash as trash_svc
|
||||||
@@ -34,6 +35,73 @@ async def list_projects() -> dict:
|
|||||||
return {"projects": [p.to_dict() for p in rows]}
|
return {"projects": [p.to_dict() for p in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
async def enter_project(project_id: int) -> dict:
|
||||||
|
"""Session-start handshake: load full context for working on a project.
|
||||||
|
|
||||||
|
Call this FIRST whenever you're about to do project-scoped work
|
||||||
|
(start_planning, create_task, update_*, anything that takes a project_id).
|
||||||
|
One round-trip returns the project, its applicable rules (both rulebook-
|
||||||
|
subscribed and project-scoped), milestone progress, open tasks, and
|
||||||
|
recently-updated notes — everything you need to know the lay of the land
|
||||||
|
before mutating.
|
||||||
|
|
||||||
|
No persistent server state: this is a read snapshot. Re-call if the
|
||||||
|
session goes idle long enough that the data feels stale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project to enter.
|
||||||
|
|
||||||
|
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||||
|
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||||
|
open_tasks, recent_notes.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
project = await projects_svc.get_project(uid, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise ValueError(f"project {project_id} not found")
|
||||||
|
|
||||||
|
applicable = await rulebooks_svc.get_applicable_rules(
|
||||||
|
project_id=project_id, user_id=uid,
|
||||||
|
)
|
||||||
|
milestone_summary = await milestones_svc.get_project_milestone_summary(
|
||||||
|
uid, project_id,
|
||||||
|
)
|
||||||
|
open_tasks, _ = await notes_svc.list_notes(
|
||||||
|
uid, is_task=True, project_id=project_id,
|
||||||
|
status=["todo", "in_progress"], sort="updated_at", limit=10,
|
||||||
|
)
|
||||||
|
recent_notes, _ = await notes_svc.list_notes(
|
||||||
|
uid, is_task=False, project_id=project_id,
|
||||||
|
sort="updated_at", limit=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project": project.to_dict(),
|
||||||
|
"milestone_summary": milestone_summary,
|
||||||
|
"applicable_rules": applicable["rules"],
|
||||||
|
"project_rules": applicable.get("project_rules", []),
|
||||||
|
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||||
|
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||||
|
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||||
|
"applicable_rules_truncated": applicable["truncated"],
|
||||||
|
"open_tasks": [
|
||||||
|
{
|
||||||
|
"id": t.id, "title": t.title, "status": t.status,
|
||||||
|
"priority": t.priority, "task_kind": t.task_kind,
|
||||||
|
"milestone_id": t.milestone_id,
|
||||||
|
}
|
||||||
|
for t in open_tasks
|
||||||
|
],
|
||||||
|
"recent_notes": [
|
||||||
|
{
|
||||||
|
"id": n.id, "title": n.title,
|
||||||
|
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||||
|
}
|
||||||
|
for n in recent_notes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_project(project_id: int) -> dict:
|
async def get_project(project_id: int) -> dict:
|
||||||
"""Fetch a Scribe project by ID.
|
"""Fetch a Scribe project by ID.
|
||||||
|
|
||||||
@@ -55,6 +123,9 @@ async def get_project(project_id: int) -> dict:
|
|||||||
data["applicable_rules"] = applicable["rules"]
|
data["applicable_rules"] = applicable["rules"]
|
||||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||||
|
data["project_rules"] = applicable.get("project_rules", [])
|
||||||
|
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||||
|
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +207,7 @@ async def delete_project(project_id: int) -> dict:
|
|||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_projects,
|
list_projects,
|
||||||
|
enter_project,
|
||||||
get_project,
|
get_project,
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
|
|||||||
@@ -54,14 +54,26 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
|||||||
|
|
||||||
async def update_rulebook(
|
async def update_rulebook(
|
||||||
rulebook_id: int, title: str = "", description: str = "",
|
rulebook_id: int, title: str = "", description: str = "",
|
||||||
|
always_on: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update an existing rulebook. Only non-empty fields are changed."""
|
"""Update an existing rulebook. Only non-empty fields are changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rulebook_id: Rulebook to update.
|
||||||
|
title: New title. Empty string leaves unchanged.
|
||||||
|
description: New description. Empty string leaves unchanged.
|
||||||
|
always_on: When True, rules in this rulebook are loaded at session
|
||||||
|
start by list_always_on_rules regardless of project context.
|
||||||
|
Pass None to leave unchanged.
|
||||||
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
if title:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if description:
|
if description:
|
||||||
fields["description"] = description
|
fields["description"] = description
|
||||||
|
if always_on is not None:
|
||||||
|
fields["always_on"] = always_on
|
||||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
||||||
if rb is None:
|
if rb is None:
|
||||||
raise ValueError(f"rulebook {rulebook_id} not found")
|
raise ValueError(f"rulebook {rulebook_id} not found")
|
||||||
@@ -200,6 +212,28 @@ async def list_rules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_always_on_rules() -> dict:
|
||||||
|
"""Return all rules from rulebooks flagged always_on for the current user.
|
||||||
|
|
||||||
|
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.
|
||||||
|
Pair with get_project(id).applicable_rules when working on a specific
|
||||||
|
project to also load that project's subscription-derived rules.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
rules = await rulebooks_svc.list_always_on_rules(uid)
|
||||||
|
return {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": r.id, "title": r.title, "statement": r.statement,
|
||||||
|
"topic_id": r.topic_id,
|
||||||
|
}
|
||||||
|
for r in rules
|
||||||
|
],
|
||||||
|
"total": len(rules),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_rule(rule_id: int) -> 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."""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -213,7 +247,7 @@ async def create_rule(
|
|||||||
topic_id: int, title: str, statement: str,
|
topic_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new rule under a topic.
|
"""Create a new rule under a topic (cross-project rulebook rule).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topic_id: The topic to attach the rule to.
|
topic_id: The topic to attach the rule to.
|
||||||
@@ -222,6 +256,9 @@ async def create_rule(
|
|||||||
why: Optional rationale — the reason the rule exists.
|
why: Optional rationale — the reason the rule exists.
|
||||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||||
order_index: Display order within the topic (default 0).
|
order_index: Display order within the topic (default 0).
|
||||||
|
|
||||||
|
For a rule that applies to a single project only, use create_project_rule
|
||||||
|
instead — no rulebook+topic ceremony required.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rule = await rulebooks_svc.create_rule(
|
rule = await rulebooks_svc.create_rule(
|
||||||
@@ -232,6 +269,35 @@ async def create_rule(
|
|||||||
return rule.to_dict()
|
return rule.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_project_rule(
|
||||||
|
project_id: int, statement: str, title: str = "",
|
||||||
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a rule scoped to a single project (no rulebook needed).
|
||||||
|
|
||||||
|
Use this when a rule only applies to one project — it bypasses the
|
||||||
|
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
|
||||||
|
applicable_rules (under project_rules) and in list_rules(project_id=...).
|
||||||
|
|
||||||
|
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.
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
derived_title = title.strip() or statement.strip().split(".")[0][:50]
|
||||||
|
rule = await rulebooks_svc.create_project_rule(
|
||||||
|
project_id=project_id, user_id=uid,
|
||||||
|
title=derived_title, statement=statement,
|
||||||
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||||
|
)
|
||||||
|
return rule.to_dict()
|
||||||
|
|
||||||
|
|
||||||
async def update_rule(
|
async def update_rule(
|
||||||
rule_id: int, title: str = "", statement: str = "",
|
rule_id: int, title: str = "", statement: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||||
@@ -298,11 +364,70 @@ async def unsubscribe_project_from_rulebook(
|
|||||||
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
|
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||||
|
|
||||||
|
async def suppress_rule_for_project(
|
||||||
|
project_id: int, rule_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Mute a single rulebook rule for one project.
|
||||||
|
|
||||||
|
The rule stays in its rulebook for other projects; only this project
|
||||||
|
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
|
||||||
|
Project-scoped rules (create_project_rule) are NOT suppressible — delete
|
||||||
|
them with delete_rule instead.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.suppress_rule_for_project(
|
||||||
|
project_id=project_id, rule_id=rule_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def unsuppress_rule_for_project(
|
||||||
|
project_id: int, rule_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.unsuppress_rule_for_project(
|
||||||
|
project_id=project_id, rule_id=rule_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def suppress_topic_for_project(
|
||||||
|
project_id: int, topic_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Mute every rule under a topic for one project.
|
||||||
|
|
||||||
|
Equivalent to suppressing each rule in the topic individually, but
|
||||||
|
auto-includes new rules added to the topic later. Idempotent.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.suppress_topic_for_project(
|
||||||
|
project_id=project_id, topic_id=topic_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def unsuppress_topic_for_project(
|
||||||
|
project_id: int, topic_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.unsuppress_topic_for_project(
|
||||||
|
project_id=project_id, topic_id=topic_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||||
list_topics, create_topic, update_topic, delete_topic,
|
list_topics, create_topic, update_topic, delete_topic,
|
||||||
list_rules, get_rule, create_rule, update_rule, delete_rule,
|
list_rules, list_always_on_rules, get_rule,
|
||||||
|
create_rule, create_project_rule, update_rule, delete_rule,
|
||||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||||
|
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||||
|
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ async def get_task(task_id: int) -> dict:
|
|||||||
data["applicable_rules"] = applicable["rules"]
|
data["applicable_rules"] = applicable["rules"]
|
||||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||||
|
data["project_rules"] = applicable.get("project_rules", [])
|
||||||
|
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||||
|
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
|
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
@@ -16,6 +16,9 @@ class Rulebook(Base, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
always_on: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, nullable=False, server_default="false"
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
@@ -31,6 +34,7 @@ class Rulebook(Base, SoftDeleteMixin):
|
|||||||
"owner_user_id": self.owner_user_id,
|
"owner_user_id": self.owner_user_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"description": self.description or "",
|
"description": self.description or "",
|
||||||
|
"always_on": self.always_on,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
@@ -77,8 +81,18 @@ class Rule(Base, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
topic_id: Mapped[int] = mapped_column(
|
# Exactly one of topic_id / project_id is set — enforced by CHECK
|
||||||
BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE")
|
# constraint ck_rule_topic_xor_project (migration 0059).
|
||||||
|
topic_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
project_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
statement: Mapped[str] = mapped_column(Text)
|
statement: Mapped[str] = mapped_column(Text)
|
||||||
@@ -98,6 +112,7 @@ class Rule(Base, SoftDeleteMixin):
|
|||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"topic_id": self.topic_id,
|
"topic_id": self.topic_id,
|
||||||
|
"project_id": self.project_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"statement": self.statement,
|
"statement": self.statement,
|
||||||
"why": self.why or "",
|
"why": self.why or "",
|
||||||
@@ -116,3 +131,22 @@ project_rulebook_subscriptions = Table(
|
|||||||
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Suppressions — let a project mute individual rules or whole topics from
|
||||||
|
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
|
||||||
|
# FKs CASCADE so the row vanishes when its parent is removed.
|
||||||
|
project_rule_suppressions = Table(
|
||||||
|
"project_rule_suppressions",
|
||||||
|
Base.metadata,
|
||||||
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
project_topic_suppressions = Table(
|
||||||
|
"project_topic_suppressions",
|
||||||
|
Base.metadata,
|
||||||
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async def get_rulebook(rulebook_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
async def update_rulebook(rulebook_id: int):
|
async def update_rulebook(rulebook_id: int):
|
||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
fields = {k: v for k, v in data.items() if k in ("title", "description")}
|
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
|
||||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
|
||||||
if rb is None:
|
if rb is None:
|
||||||
return jsonify({"error": "rulebook not found"}), 404
|
return jsonify({"error": "rulebook not found"}), 404
|
||||||
@@ -239,3 +239,75 @@ async def get_project_rules(project_id: int):
|
|||||||
project_id=project_id, user_id=_uid(),
|
project_id=project_id, user_id=_uid(),
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
||||||
|
@login_required
|
||||||
|
async def suppress_project_rule(project_id: int, rule_id: int):
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.suppress_rule_for_project(
|
||||||
|
project_id=project_id, rule_id=rule_id, user_id=_uid(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
||||||
|
@login_required
|
||||||
|
async def unsuppress_project_rule(project_id: int, rule_id: int):
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.unsuppress_rule_for_project(
|
||||||
|
project_id=project_id, rule_id=rule_id, user_id=_uid(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
||||||
|
@login_required
|
||||||
|
async def suppress_project_topic(project_id: int, topic_id: int):
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.suppress_topic_for_project(
|
||||||
|
project_id=project_id, topic_id=topic_id, user_id=_uid(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
||||||
|
@login_required
|
||||||
|
async def unsuppress_project_topic(project_id: int, topic_id: int):
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.unsuppress_topic_for_project(
|
||||||
|
project_id=project_id, topic_id=topic_id, user_id=_uid(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||||
|
@login_required
|
||||||
|
async def create_project_rule(project_id: int):
|
||||||
|
"""Create a rule scoped to a single project. Frontend fast path."""
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
statement = (data.get("statement") or "").strip()
|
||||||
|
if not statement:
|
||||||
|
return jsonify({"error": "statement is required"}), 400
|
||||||
|
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
|
||||||
|
try:
|
||||||
|
rule = await rulebooks_svc.create_project_rule(
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=_uid(),
|
||||||
|
title=title,
|
||||||
|
statement=statement,
|
||||||
|
why=data.get("why", ""),
|
||||||
|
how_to_apply=data.get("how_to_apply", ""),
|
||||||
|
order_index=data.get("order_index", 0),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return jsonify(rule.to_dict()), 201
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
|
|||||||
"applicable_rules": applicable["rules"],
|
"applicable_rules": applicable["rules"],
|
||||||
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||||
"applicable_rules_truncated": applicable["truncated"],
|
"applicable_rules_truncated": applicable["truncated"],
|
||||||
|
"project_rules": applicable.get("project_rules", []),
|
||||||
|
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||||
|
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||||
"project_goal": getattr(project, "goal", "") or "",
|
"project_goal": getattr(project, "goal", "") or "",
|
||||||
"open_task_count": open_count,
|
"open_task_count": open_count,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async def update_rulebook(
|
|||||||
rb = result.scalar_one_or_none()
|
rb = result.scalar_one_or_none()
|
||||||
if rb is None:
|
if rb is None:
|
||||||
return None
|
return None
|
||||||
allowed = {"title", "description"}
|
allowed = {"title", "description", "always_on"}
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if key in allowed and value is not None:
|
if key in allowed and value is not None:
|
||||||
setattr(rb, key, value)
|
setattr(rb, key, value)
|
||||||
@@ -242,6 +242,44 @@ async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
|
|||||||
raise ValueError(f"topic {topic_id} not found")
|
raise ValueError(f"topic {topic_id} not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
|
||||||
|
"""Raise ValueError if project doesn't exist or isn't owned by user."""
|
||||||
|
from fabledassistant.models.project import Project
|
||||||
|
result = await session.execute(
|
||||||
|
select(Project).where(
|
||||||
|
Project.id == project_id,
|
||||||
|
Project.user_id == user_id,
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
raise ValueError(f"project {project_id} not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
|
||||||
|
"""Raise ValueError if rule isn't a rulebook rule the user owns.
|
||||||
|
|
||||||
|
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
|
||||||
|
suppressible — they belong to the project; delete them instead. This
|
||||||
|
helper deliberately excludes them.
|
||||||
|
"""
|
||||||
|
from fabledassistant.models.rulebook import Rule
|
||||||
|
result = await session.execute(
|
||||||
|
select(Rule)
|
||||||
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
|
.where(
|
||||||
|
Rule.id == rule_id,
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
|
||||||
|
|
||||||
|
|
||||||
async def create_rule(
|
async def create_rule(
|
||||||
topic_id: int, user_id: int, title: str, statement: str,
|
topic_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
@@ -262,6 +300,32 @@ async def create_rule(
|
|||||||
return 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,
|
||||||
|
) -> Rule:
|
||||||
|
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||||
|
|
||||||
|
Project-scoped rules apply only to the named project; they don't
|
||||||
|
propagate via rulebook subscriptions. Topic_id is left NULL — the
|
||||||
|
CHECK constraint enforces exactly-one of (topic_id, project_id).
|
||||||
|
"""
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
rule = Rule(
|
||||||
|
project_id=project_id,
|
||||||
|
title=title,
|
||||||
|
statement=statement,
|
||||||
|
why=why or None,
|
||||||
|
how_to_apply=how_to_apply or None,
|
||||||
|
order_index=order_index,
|
||||||
|
)
|
||||||
|
session.add(rule)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(rule)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
async def list_rules(
|
async def list_rules(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
rulebook_id: int | None = None,
|
rulebook_id: int | None = None,
|
||||||
@@ -270,7 +334,12 @@ async def list_rules(
|
|||||||
) -> list[Rule]:
|
) -> list[Rule]:
|
||||||
"""List rules filtered by any of the three IDs. All filters are ownership-scoped.
|
"""List rules filtered by any of the three IDs. All filters are ownership-scoped.
|
||||||
|
|
||||||
project_id resolves rules through project_rulebook_subscriptions.
|
When project_id is set, the result includes both rulebook rules reached via
|
||||||
|
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id).
|
||||||
|
When rulebook_id or topic_id is set, project-scoped rules are excluded by
|
||||||
|
construction (they have neither). With no filter, only rulebook rules are
|
||||||
|
returned — adding all of a user's project-scoped rules unprompted would
|
||||||
|
surprise existing callers.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.models.rulebook import project_rulebook_subscriptions
|
from fabledassistant.models.rulebook import project_rulebook_subscriptions
|
||||||
|
|
||||||
@@ -302,38 +371,101 @@ async def list_rules(
|
|||||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return list(result.scalars().all())
|
rulebook_rules = list(result.scalars().all())
|
||||||
|
|
||||||
|
if not project_id:
|
||||||
|
return rulebook_rules
|
||||||
|
|
||||||
|
# Project-scoped rules (topic_id IS NULL, project_id matches).
|
||||||
|
# Verifies ownership by joining Project on user_id.
|
||||||
|
from fabledassistant.models.project import Project
|
||||||
|
proj_stmt = (
|
||||||
|
select(Rule)
|
||||||
|
.join(Project, Rule.project_id == Project.id)
|
||||||
|
.where(
|
||||||
|
Project.user_id == user_id,
|
||||||
|
Rule.project_id == project_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Rule.order_index, Rule.title)
|
||||||
|
)
|
||||||
|
proj_result = await session.execute(proj_stmt)
|
||||||
|
return rulebook_rules + list(proj_result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
|
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
|
||||||
|
"""Return all rules from rulebooks flagged always_on for the user.
|
||||||
|
|
||||||
|
Called by the MCP tool of the same name at session start to load the
|
||||||
|
standing rules that apply regardless of which project (if any) is in
|
||||||
|
scope. Ordering matches list_rules so results are stable across calls.
|
||||||
|
"""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Rule)
|
select(Rule)
|
||||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
.where(
|
.where(
|
||||||
Rule.id == rule_id,
|
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rulebook.always_on.is_(True),
|
||||||
Rule.deleted_at.is_(None),
|
Rule.deleted_at.is_(None),
|
||||||
RulebookTopic.deleted_at.is_(None),
|
RulebookTopic.deleted_at.is_(None),
|
||||||
Rulebook.deleted_at.is_(None),
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
.order_by(
|
||||||
|
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
|
||||||
|
"""Fetch a rule by id, scoped to user owning either its rulebook
|
||||||
|
(via topic) or its project (via project_id). Honors soft-delete.
|
||||||
|
Returns None when not found or not owned.
|
||||||
|
"""
|
||||||
|
from fabledassistant.models.project import Project
|
||||||
|
|
||||||
|
# Path A — rulebook rule.
|
||||||
|
rulebook_rule = (await session.execute(
|
||||||
|
select(Rule)
|
||||||
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
|
.where(
|
||||||
|
Rule.id == rule_id,
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if rulebook_rule is not None:
|
||||||
|
return rulebook_rule
|
||||||
|
|
||||||
|
# Path B — project-scoped rule.
|
||||||
|
project_rule = (await session.execute(
|
||||||
|
select(Rule)
|
||||||
|
.join(Project, Rule.project_id == Project.id)
|
||||||
|
.where(
|
||||||
|
Rule.id == rule_id,
|
||||||
|
Project.user_id == user_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
return project_rule
|
||||||
|
|
||||||
|
|
||||||
|
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
|
||||||
|
async with async_session() as session:
|
||||||
|
return await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
select(Rule)
|
|
||||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
|
||||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
|
||||||
.where(
|
|
||||||
Rule.id == rule_id,
|
|
||||||
Rulebook.owner_user_id == user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rule = result.scalar_one_or_none()
|
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return None
|
return None
|
||||||
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
|
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
|
||||||
@@ -347,16 +479,7 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
|||||||
|
|
||||||
async def delete_rule(rule_id: int, user_id: int) -> None:
|
async def delete_rule(rule_id: int, user_id: int) -> None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
select(Rule)
|
|
||||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
|
||||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
|
||||||
.where(
|
|
||||||
Rule.id == rule_id,
|
|
||||||
Rulebook.owner_user_id == user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rule = result.scalar_one_or_none()
|
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return
|
return
|
||||||
await session.delete(rule)
|
await session.delete(rule)
|
||||||
@@ -404,19 +527,115 @@ async def unsubscribe_project(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||||
|
|
||||||
|
async def suppress_rule_for_project(
|
||||||
|
project_id: int, rule_id: int, user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Mute one rulebook rule for one project. Idempotent."""
|
||||||
|
from fabledassistant.models.rulebook import project_rule_suppressions
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
await _assert_rulebook_rule_owned(session, rule_id, user_id)
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
insert(project_rule_suppressions).values(
|
||||||
|
project_id=project_id, rule_id=rule_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback() # PK collision = already suppressed; fine.
|
||||||
|
|
||||||
|
|
||||||
|
async def unsuppress_rule_for_project(
|
||||||
|
project_id: int, rule_id: int, user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Unmute one rulebook rule for one project. Idempotent."""
|
||||||
|
from fabledassistant.models.rulebook import project_rule_suppressions
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
await session.execute(
|
||||||
|
sql_delete(project_rule_suppressions).where(
|
||||||
|
project_rule_suppressions.c.project_id == project_id,
|
||||||
|
project_rule_suppressions.c.rule_id == rule_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def suppress_topic_for_project(
|
||||||
|
project_id: int, topic_id: int, user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Mute every rule under one topic for one project. Idempotent."""
|
||||||
|
from fabledassistant.models.rulebook import project_topic_suppressions
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
await _assert_topic_owned(session, topic_id, user_id)
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
insert(project_topic_suppressions).values(
|
||||||
|
project_id=project_id, topic_id=topic_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def unsuppress_topic_for_project(
|
||||||
|
project_id: int, topic_id: int, user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Unmute a topic for one project. Idempotent."""
|
||||||
|
from fabledassistant.models.rulebook import project_topic_suppressions
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
await session.execute(
|
||||||
|
sql_delete(project_topic_suppressions).where(
|
||||||
|
project_topic_suppressions.c.project_id == project_id,
|
||||||
|
project_topic_suppressions.c.topic_id == topic_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def get_applicable_rules(
|
async def get_applicable_rules(
|
||||||
project_id: int, user_id: int, limit: int = 50,
|
project_id: int, user_id: int, limit: int = 50,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return rules applicable to a project via its subscriptions.
|
"""Return rules applicable to a project — both via rulebook subscriptions
|
||||||
|
and project-scoped rules (Rule.project_id matches), with suppressed rules
|
||||||
|
and suppressed topics filtered out.
|
||||||
|
|
||||||
Shape:
|
Shape:
|
||||||
{
|
{
|
||||||
"rules": [{id, title, statement, topic_title, rulebook_title}, ...],
|
"rules": [{id, title, statement,
|
||||||
|
topic_id, topic_title,
|
||||||
|
rulebook_id, rulebook_title}, ...],
|
||||||
|
"project_rules": [{id, title, statement}, ...],
|
||||||
|
"suppressed_rules": [{id, title,
|
||||||
|
topic_id, topic_title,
|
||||||
|
rulebook_id, rulebook_title}, ...],
|
||||||
|
"suppressed_topics": [{id, title,
|
||||||
|
rulebook_id, rulebook_title}, ...],
|
||||||
"truncated": bool,
|
"truncated": bool,
|
||||||
"subscribed_rulebooks": [{id, title}, ...]
|
"subscribed_rulebooks": [{id, title}, ...]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
`rules` is the subscription-derived set with project-level suppressions
|
||||||
|
applied. `project_rules` is the project-scoped set (never suppressed —
|
||||||
|
delete instead). `suppressed_rules` / `suppressed_topics` carry the
|
||||||
|
titles + rulebook context callers need to display what was filtered
|
||||||
|
without round-tripping for names.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.models.rulebook import project_rulebook_subscriptions
|
from fabledassistant.models.rulebook import (
|
||||||
|
project_rulebook_subscriptions,
|
||||||
|
project_rule_suppressions,
|
||||||
|
project_topic_suppressions,
|
||||||
|
)
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
# Subscribed rulebooks for the project (ownership-scoped).
|
# Subscribed rulebooks for the project (ownership-scoped).
|
||||||
@@ -438,11 +657,64 @@ async def get_applicable_rules(
|
|||||||
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
|
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
# Applicable rules (limit + 1 so we can detect truncation).
|
# Suppressed rules — joined to topic + rulebook so callers can render
|
||||||
|
# context without a follow-up lookup. Ownership-scoped via rulebook.
|
||||||
|
suppressed_rules_q = (
|
||||||
|
select(
|
||||||
|
Rule.id, Rule.title,
|
||||||
|
RulebookTopic.id.label("topic_id"),
|
||||||
|
RulebookTopic.title.label("topic_title"),
|
||||||
|
Rulebook.id.label("rulebook_id"),
|
||||||
|
Rulebook.title.label("rulebook_title"),
|
||||||
|
)
|
||||||
|
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
|
||||||
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
|
.where(
|
||||||
|
project_rule_suppressions.c.project_id == project_id,
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
)
|
||||||
|
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
|
||||||
|
)
|
||||||
|
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
|
||||||
|
suppressed_rules = [
|
||||||
|
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
|
||||||
|
"rulebook_id": rbi, "rulebook_title": rbt}
|
||||||
|
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
|
||||||
|
]
|
||||||
|
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
|
||||||
|
|
||||||
|
# Suppressed topics — joined to rulebook for context.
|
||||||
|
suppressed_topics_q = (
|
||||||
|
select(
|
||||||
|
RulebookTopic.id, RulebookTopic.title,
|
||||||
|
Rulebook.id.label("rulebook_id"),
|
||||||
|
Rulebook.title.label("rulebook_title"),
|
||||||
|
)
|
||||||
|
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
|
||||||
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
|
.where(
|
||||||
|
project_topic_suppressions.c.project_id == project_id,
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
)
|
||||||
|
.order_by(Rulebook.title, RulebookTopic.title)
|
||||||
|
)
|
||||||
|
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
|
||||||
|
suppressed_topics = [
|
||||||
|
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
|
||||||
|
for tid, tt, rbi, rbt in suppressed_topic_rows
|
||||||
|
]
|
||||||
|
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
|
||||||
|
|
||||||
|
# Applicable rules (limit + 1 so we can detect truncation). Filter
|
||||||
|
# in SQL so truncation reflects the post-suppression count, not the
|
||||||
|
# raw subscription count.
|
||||||
rules_q = (
|
rules_q = (
|
||||||
select(
|
select(
|
||||||
Rule.id, Rule.title, Rule.statement,
|
Rule.id, Rule.title, Rule.statement,
|
||||||
|
RulebookTopic.id.label("topic_id"),
|
||||||
RulebookTopic.title.label("topic_title"),
|
RulebookTopic.title.label("topic_title"),
|
||||||
|
Rulebook.id.label("rulebook_id"),
|
||||||
Rulebook.title.label("rulebook_title"),
|
Rulebook.title.label("rulebook_title"),
|
||||||
)
|
)
|
||||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
@@ -463,18 +735,45 @@ async def get_applicable_rules(
|
|||||||
)
|
)
|
||||||
.limit(limit + 1)
|
.limit(limit + 1)
|
||||||
)
|
)
|
||||||
|
if suppressed_rule_ids:
|
||||||
|
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))
|
||||||
rule_rows = (await session.execute(rules_q)).all()
|
rule_rows = (await session.execute(rules_q)).all()
|
||||||
truncated = len(rule_rows) > limit
|
truncated = len(rule_rows) > limit
|
||||||
rules = [
|
rules = [
|
||||||
{
|
{
|
||||||
"id": rid, "title": rtitle, "statement": stmt,
|
"id": rid, "title": rtitle, "statement": stmt,
|
||||||
"topic_title": tt, "rulebook_title": rbt,
|
"topic_id": ti, "topic_title": tt,
|
||||||
|
"rulebook_id": rbi, "rulebook_title": rbt,
|
||||||
}
|
}
|
||||||
for rid, rtitle, stmt, tt, rbt in rule_rows[:limit]
|
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Project-scoped rules — verifies ownership via Project.user_id.
|
||||||
|
from fabledassistant.models.project import Project
|
||||||
|
proj_rules_q = (
|
||||||
|
select(Rule.id, Rule.title, Rule.statement)
|
||||||
|
.join(Project, Rule.project_id == Project.id)
|
||||||
|
.where(
|
||||||
|
Project.user_id == user_id,
|
||||||
|
Rule.project_id == project_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Rule.order_index, Rule.title)
|
||||||
|
)
|
||||||
|
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
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"rules": rules,
|
"rules": rules,
|
||||||
|
"project_rules": project_rules,
|
||||||
|
"suppressed_rules": suppressed_rules,
|
||||||
|
"suppressed_topics": suppressed_topics,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"subscribed_rulebooks": subscribed_rulebooks,
|
"subscribed_rulebooks": subscribed_rulebooks,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,25 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
|
|||||||
if etype == "project":
|
if etype == "project":
|
||||||
await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now)
|
await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now)
|
||||||
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
|
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
|
||||||
|
# Project-scoped rules cascade with the project they're attached to.
|
||||||
|
await _set(session, Rule, [Rule.project_id == eid], batch, now)
|
||||||
|
# Suppressions are pure associations (no deleted_at) — hard-delete
|
||||||
|
# them here so restoring the project doesn't bring stale mutes back.
|
||||||
|
# FK CASCADE would handle a full DELETE on the project row, but the
|
||||||
|
# soft-delete path keeps the project row alive; this guarantees the
|
||||||
|
# rows are gone whether or not the project ever gets purged.
|
||||||
|
from sqlalchemy import delete as _sql_delete
|
||||||
|
from fabledassistant.models.rulebook import (
|
||||||
|
project_rule_suppressions, project_topic_suppressions,
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
_sql_delete(project_rule_suppressions)
|
||||||
|
.where(project_rule_suppressions.c.project_id == eid)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
_sql_delete(project_topic_suppressions)
|
||||||
|
.where(project_topic_suppressions.c.project_id == eid)
|
||||||
|
)
|
||||||
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
|
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
|
||||||
elif etype == "milestone":
|
elif etype == "milestone":
|
||||||
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
|
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from fabledassistant.mcp._context import _user_id_ctx
|
from fabledassistant.mcp._context import _user_id_ctx
|
||||||
from fabledassistant.mcp.tools.projects import (
|
from fabledassistant.mcp.tools.projects import (
|
||||||
list_projects, get_project, create_project,
|
list_projects, get_project, create_project,
|
||||||
update_project,
|
update_project, enter_project,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,3 +130,75 @@ async def test_update_project_raises_when_not_found():
|
|||||||
):
|
):
|
||||||
with pytest.raises(ValueError, match="project 999 not found"):
|
with pytest.raises(ValueError, match="project 999 not found"):
|
||||||
await update_project(project_id=999, title="x")
|
await update_project(project_id=999, title="x")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_project_composes_full_context():
|
||||||
|
"""enter_project pulls project + rules + milestone summary + open tasks +
|
||||||
|
recent notes in one composed call."""
|
||||||
|
p = _fake_project(id=5, title="P")
|
||||||
|
applicable_payload = {
|
||||||
|
"rules": [{"id": 1, "title": "r1", "statement": "s",
|
||||||
|
"topic_title": "t", "rulebook_title": "rb"}],
|
||||||
|
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
|
||||||
|
"truncated": False,
|
||||||
|
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
|
||||||
|
}
|
||||||
|
milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}]
|
||||||
|
|
||||||
|
task1 = MagicMock()
|
||||||
|
task1.id = 100; task1.title = "T1"; task1.status = "in_progress"
|
||||||
|
task1.priority = "high"; task1.task_kind = "work"; task1.milestone_id = 10
|
||||||
|
note1 = MagicMock()
|
||||||
|
note1.id = 200; note1.title = "N1"
|
||||||
|
note1.updated_at = None # avoids datetime mocking
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
|
||||||
|
AsyncMock(return_value=p),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||||
|
AsyncMock(return_value=applicable_payload),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||||
|
AsyncMock(return_value=milestone_summary),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.mcp.tools.projects.notes_svc.list_notes",
|
||||||
|
AsyncMock(side_effect=[([task1], 1), ([note1], 1)]),
|
||||||
|
):
|
||||||
|
out = await enter_project(project_id=5)
|
||||||
|
|
||||||
|
assert out["project"]["id"] == 5
|
||||||
|
assert out["milestone_summary"] == milestone_summary
|
||||||
|
assert out["applicable_rules"][0]["title"] == "r1"
|
||||||
|
assert out["project_rules"][0]["id"] == 99
|
||||||
|
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
|
||||||
|
assert out["open_tasks"][0]["id"] == 100
|
||||||
|
assert out["open_tasks"][0]["status"] == "in_progress"
|
||||||
|
assert out["recent_notes"][0]["id"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_project_raises_when_project_not_found():
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
):
|
||||||
|
with pytest.raises(ValueError, match="project 999 not found"):
|
||||||
|
await enter_project(project_id=999)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_project_registered_in_register():
|
||||||
|
"""register(mcp) registers enter_project alongside the existing tools."""
|
||||||
|
from fabledassistant.mcp.tools.projects import register
|
||||||
|
registered: list[str] = []
|
||||||
|
|
||||||
|
class FakeMCP:
|
||||||
|
def tool(self, name=None):
|
||||||
|
def decorator(fn):
|
||||||
|
registered.append(name)
|
||||||
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
register(FakeMCP())
|
||||||
|
assert "enter_project" in registered
|
||||||
|
|||||||
@@ -179,8 +179,156 @@ def test_register_attaches_all_sixteen_tools():
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
register(FakeMCP())
|
register(FakeMCP())
|
||||||
assert len(registered) == 16
|
assert len(registered) == 22
|
||||||
# spot-check a few names
|
# spot-check a few names
|
||||||
assert "list_rulebooks" in registered
|
assert "list_rulebooks" in registered
|
||||||
assert "create_rule" in registered
|
assert "create_rule" in registered
|
||||||
assert "subscribe_project_to_rulebook" in registered
|
assert "subscribe_project_to_rulebook" in registered
|
||||||
|
assert "list_always_on_rules" in registered
|
||||||
|
assert "create_project_rule" in registered
|
||||||
|
assert "suppress_rule_for_project" in registered
|
||||||
|
assert "unsuppress_rule_for_project" in registered
|
||||||
|
assert "suppress_topic_for_project" in registered
|
||||||
|
assert "unsuppress_topic_for_project" in registered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
||||||
|
AsyncMock(return_value=[]),
|
||||||
|
):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import list_always_on_rules
|
||||||
|
out = await list_always_on_rules()
|
||||||
|
assert out == {"rules": [], "total": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_always_on_rules_projects_each_rule():
|
||||||
|
rules = [_fake_rule(id=100), _fake_rule(id=101)]
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
||||||
|
AsyncMock(return_value=rules),
|
||||||
|
):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import list_always_on_rules
|
||||||
|
out = await list_always_on_rules()
|
||||||
|
assert out["total"] == 2
|
||||||
|
assert {r["id"] for r in out["rules"]} == {100, 101}
|
||||||
|
assert all("topic_id" in r for r in out["rules"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_rulebook_forwards_always_on_when_set():
|
||||||
|
rb = _fake_rulebook(id=1, title="t")
|
||||||
|
mock = AsyncMock(return_value=rb)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import update_rulebook
|
||||||
|
await update_rulebook(rulebook_id=1, always_on=True)
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
assert kwargs.get("always_on") is True
|
||||||
|
assert "title" not in kwargs
|
||||||
|
assert "description" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_rulebook_omits_always_on_when_none():
|
||||||
|
rb = _fake_rulebook(id=1, title="t")
|
||||||
|
mock = AsyncMock(return_value=rb)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import update_rulebook
|
||||||
|
await update_rulebook(rulebook_id=1, title="new title")
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
assert "always_on" not in kwargs
|
||||||
|
assert kwargs["title"] == "new title"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_project_rule_passes_required_fields():
|
||||||
|
rule = _fake_rule()
|
||||||
|
mock = AsyncMock(return_value=rule)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import create_project_rule
|
||||||
|
await create_project_rule(
|
||||||
|
project_id=42,
|
||||||
|
statement="Always run migrations through alembic, not raw SQL.",
|
||||||
|
why="audit trail",
|
||||||
|
)
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
assert kwargs["user_id"] == 7
|
||||||
|
assert kwargs["project_id"] == 42
|
||||||
|
assert kwargs["statement"].startswith("Always run migrations")
|
||||||
|
assert kwargs["why"] == "audit trail"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_project_rule_derives_title_from_statement():
|
||||||
|
rule = _fake_rule()
|
||||||
|
mock = AsyncMock(return_value=rule)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import create_project_rule
|
||||||
|
await create_project_rule(
|
||||||
|
project_id=42,
|
||||||
|
statement="Avoid auto-generated docstrings. Reviewers find them noise.",
|
||||||
|
)
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
# Title should be derived from the first sentence, capped at 50 chars
|
||||||
|
assert kwargs["title"] == "Avoid auto-generated docstrings"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_project_rule_uses_explicit_title_when_given():
|
||||||
|
rule = _fake_rule()
|
||||||
|
mock = AsyncMock(return_value=rule)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import create_project_rule
|
||||||
|
await create_project_rule(
|
||||||
|
project_id=42,
|
||||||
|
statement="anything",
|
||||||
|
title="no auto-docstrings",
|
||||||
|
)
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
assert kwargs["title"] == "no auto-docstrings"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suppress_rule_for_project_passes_through():
|
||||||
|
mock = AsyncMock(return_value=None)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import suppress_rule_for_project
|
||||||
|
out = await suppress_rule_for_project(project_id=3, rule_id=17)
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
|
||||||
|
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsuppress_rule_for_project_passes_through():
|
||||||
|
mock = AsyncMock(return_value=None)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import unsuppress_rule_for_project
|
||||||
|
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
|
||||||
|
kwargs = mock.call_args.kwargs
|
||||||
|
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
|
||||||
|
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suppress_topic_for_project_passes_through():
|
||||||
|
mock = AsyncMock(return_value=None)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import suppress_topic_for_project
|
||||||
|
out = await suppress_topic_for_project(project_id=3, topic_id=22)
|
||||||
|
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": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsuppress_topic_for_project_passes_through():
|
||||||
|
mock = AsyncMock(return_value=None)
|
||||||
|
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
|
||||||
|
from fabledassistant.mcp.tools.rulebooks import unsuppress_topic_for_project
|
||||||
|
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
|
||||||
|
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}
|
||||||
|
|||||||
@@ -44,13 +44,74 @@ def test_service_signatures_require_user_id():
|
|||||||
"create_rulebook", "list_rulebooks", "get_rulebook",
|
"create_rulebook", "list_rulebooks", "get_rulebook",
|
||||||
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
|
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
|
||||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||||
"create_rule", "list_rules", "get_rule", "update_rule", "delete_rule",
|
"create_rule", "create_project_rule",
|
||||||
|
"list_rules", "list_always_on_rules",
|
||||||
|
"get_rule", "update_rule", "delete_rule",
|
||||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||||
|
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||||
|
"suppress_topic_for_project", "unsuppress_topic_for_project",
|
||||||
):
|
):
|
||||||
sig = inspect.signature(getattr(svc, fn_name))
|
sig = inspect.signature(getattr(svc, fn_name))
|
||||||
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
|
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_model_carries_project_id_and_topic_id_nullable():
|
||||||
|
"""Migration 0059 made topic_id nullable and added project_id."""
|
||||||
|
from fabledassistant.models.rulebook import Rule
|
||||||
|
assert "project_id" in Rule.__table__.columns
|
||||||
|
assert Rule.__table__.columns["topic_id"].nullable is True
|
||||||
|
assert Rule.__table__.columns["project_id"].nullable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rule_route_exists():
|
||||||
|
"""POST /api/projects/<id>/rules — the frontend fast-path endpoint."""
|
||||||
|
from fabledassistant.routes import rulebooks as rb_routes
|
||||||
|
assert callable(getattr(rb_routes, "create_project_rule"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppression_route_handlers_exist():
|
||||||
|
"""The 4 suppression endpoint handlers are registered as Python callables."""
|
||||||
|
from fabledassistant.routes import rulebooks as rb_routes
|
||||||
|
for name in (
|
||||||
|
"suppress_project_rule", "unsuppress_project_rule",
|
||||||
|
"suppress_project_topic", "unsuppress_project_topic",
|
||||||
|
):
|
||||||
|
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppression_association_tables_declared():
|
||||||
|
"""Migration 0060 created two new association tables; the models module
|
||||||
|
must declare matching Table() objects so the rest of the service layer
|
||||||
|
can reference them via .c.<column>."""
|
||||||
|
from fabledassistant.models import rulebook as rb_models
|
||||||
|
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
|
||||||
|
tbl = getattr(rb_models, tbl_name, None)
|
||||||
|
assert tbl is not None, f"models.rulebook missing {tbl_name}"
|
||||||
|
cols = {c.name for c in tbl.columns}
|
||||||
|
assert "project_id" in cols
|
||||||
|
assert "rule_id" in cols or "topic_id" in cols
|
||||||
|
|
||||||
|
|
||||||
|
def test_rulebook_model_carries_always_on():
|
||||||
|
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
|
||||||
|
from fabledassistant.models.rulebook import Rulebook
|
||||||
|
assert "always_on" in Rulebook.__table__.columns
|
||||||
|
col = Rulebook.__table__.columns["always_on"]
|
||||||
|
assert col.nullable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rulebook_route_accepts_always_on():
|
||||||
|
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
|
||||||
|
|
||||||
|
The handler filters body keys against a whitelist; that whitelist needs to
|
||||||
|
include always_on or toggling from the UI silently drops the field.
|
||||||
|
"""
|
||||||
|
import inspect as _inspect
|
||||||
|
from fabledassistant.routes import rulebooks as rb_routes
|
||||||
|
src = _inspect.getsource(rb_routes.update_rulebook)
|
||||||
|
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
|
||||||
|
|
||||||
|
|
||||||
def test_rule_and_subscription_handlers_callable():
|
def test_rule_and_subscription_handlers_callable():
|
||||||
from fabledassistant.routes import rulebooks as rb_routes
|
from fabledassistant.routes import rulebooks as rb_routes
|
||||||
for name in (
|
for name in (
|
||||||
|
|||||||
@@ -241,18 +241,31 @@ async def test_subscribe_project_requires_owned_rulebook():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty():
|
||||||
|
"""A MagicMock result whose .all() returns [] (or .scalars().all() returns [])."""
|
||||||
|
r = MagicMock()
|
||||||
|
r.all.return_value = []
|
||||||
|
r.scalars.return_value.all.return_value = []
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_applicable_rules_returns_shape():
|
async def test_get_applicable_rules_returns_shape():
|
||||||
"""get_applicable_rules returns {rules, truncated, subscribed_rulebooks}."""
|
"""get_applicable_rules returns the full projection — including the
|
||||||
|
new suppression fields and rulebook/topic IDs on each rule."""
|
||||||
mock_session = _make_mock_session()
|
mock_session = _make_mock_session()
|
||||||
sub_result = MagicMock()
|
sub_result = MagicMock()
|
||||||
sub_result.all.return_value = [(1, "FabledSword family")]
|
sub_result.all.return_value = [(1, "FabledSword family")]
|
||||||
rules_result = MagicMock()
|
rules_result = MagicMock()
|
||||||
rules_result.all.return_value = [
|
rules_result.all.return_value = [
|
||||||
(i, f"Rule {i}", f"Statement {i}", "git-workflow", "FabledSword family")
|
# (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")
|
||||||
for i in range(50)
|
for i in range(50)
|
||||||
]
|
]
|
||||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
|
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, rules_q, proj_rules_q
|
||||||
|
mock_session.execute = AsyncMock(side_effect=[
|
||||||
|
sub_result, _empty(), _empty(), rules_result, _empty(),
|
||||||
|
])
|
||||||
|
|
||||||
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
mock_cls.return_value = mock_session
|
mock_cls.return_value = mock_session
|
||||||
@@ -260,11 +273,19 @@ async def test_get_applicable_rules_returns_shape():
|
|||||||
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
||||||
|
|
||||||
assert "rules" in result
|
assert "rules" in result
|
||||||
|
assert "project_rules" in result
|
||||||
|
assert "suppressed_rules" in result
|
||||||
|
assert "suppressed_topics" in result
|
||||||
assert "truncated" in result
|
assert "truncated" in result
|
||||||
assert "subscribed_rulebooks" in result
|
assert "subscribed_rulebooks" in result
|
||||||
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
|
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
|
||||||
assert len(result["rules"]) == 50
|
assert len(result["rules"]) == 50
|
||||||
assert result["truncated"] is False # exactly 50, not over
|
assert result["rules"][0]["topic_id"] == 2
|
||||||
|
assert result["rules"][0]["rulebook_id"] == 1
|
||||||
|
assert result["project_rules"] == []
|
||||||
|
assert result["suppressed_rules"] == []
|
||||||
|
assert result["suppressed_topics"] == []
|
||||||
|
assert result["truncated"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -274,11 +295,12 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
|||||||
sub_result = MagicMock()
|
sub_result = MagicMock()
|
||||||
sub_result.all.return_value = []
|
sub_result.all.return_value = []
|
||||||
rules_result = MagicMock()
|
rules_result = MagicMock()
|
||||||
# 51 rows means truncation detected
|
|
||||||
rules_result.all.return_value = [
|
rules_result.all.return_value = [
|
||||||
(i, f"r{i}", "stmt", "topic", "rb") for i in range(51)
|
(i, f"r{i}", "stmt", 2, "topic", 1, "rb") for i in range(51)
|
||||||
]
|
]
|
||||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
|
mock_session.execute = AsyncMock(side_effect=[
|
||||||
|
sub_result, _empty(), _empty(), rules_result, _empty(),
|
||||||
|
])
|
||||||
|
|
||||||
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
mock_cls.return_value = mock_session
|
mock_cls.return_value = mock_session
|
||||||
@@ -287,3 +309,57 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
|||||||
|
|
||||||
assert result["truncated"] is True
|
assert result["truncated"] is True
|
||||||
assert len(result["rules"]) == 50
|
assert len(result["rules"]) == 50
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||||
|
"""Project-scoped rules surface in the project_rules field."""
|
||||||
|
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."),
|
||||||
|
]
|
||||||
|
mock_session.execute = AsyncMock(side_effect=[
|
||||||
|
_empty(), _empty(), _empty(), _empty(), proj_rules_result,
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from fabledassistant.services.rulebooks import get_applicable_rules
|
||||||
|
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||||
|
|
||||||
|
assert len(result["project_rules"]) == 2
|
||||||
|
assert result["project_rules"][0]["title"] == "Use alembic"
|
||||||
|
assert result["project_rules"][1]["id"] == 101
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||||
|
"""Suppressed rules and topics come back with full title + rulebook context
|
||||||
|
so the UI can render them without an extra round-trip."""
|
||||||
|
mock_session = _make_mock_session()
|
||||||
|
suppressed_rules_result = MagicMock()
|
||||||
|
suppressed_rules_result.all.return_value = [
|
||||||
|
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title)
|
||||||
|
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"),
|
||||||
|
]
|
||||||
|
suppressed_topics_result = MagicMock()
|
||||||
|
suppressed_topics_result.all.return_value = [
|
||||||
|
# (topic_id, topic_title, rulebook_id, rulebook_title)
|
||||||
|
(22, "design-system", 1, "FabledSword family"),
|
||||||
|
]
|
||||||
|
mock_session.execute = AsyncMock(side_effect=[
|
||||||
|
_empty(), suppressed_rules_result, suppressed_topics_result, _empty(), _empty(),
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from fabledassistant.services.rulebooks import get_applicable_rules
|
||||||
|
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||||
|
|
||||||
|
assert len(result["suppressed_rules"]) == 1
|
||||||
|
assert result["suppressed_rules"][0]["id"] == 17
|
||||||
|
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
|
||||||
|
assert len(result["suppressed_topics"]) == 1
|
||||||
|
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||||
|
|||||||
@@ -48,18 +48,24 @@ async def test_delete_returns_none_when_not_found():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_project_cascades_three_tables():
|
async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions():
|
||||||
session = _make_mock_session()
|
session = _make_mock_session()
|
||||||
# exists-check + 3 cascade updates (notes, milestones, project)
|
# exists-check + 6 cascade ops:
|
||||||
|
# notes (soft) → milestones (soft) → project-scoped rules (soft) →
|
||||||
|
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
|
||||||
|
# project (soft)
|
||||||
session.execute = AsyncMock(side_effect=[
|
session.execute = AsyncMock(side_effect=[
|
||||||
_exists_result(True), MagicMock(), MagicMock(), MagicMock(),
|
_exists_result(True),
|
||||||
|
MagicMock(), MagicMock(), MagicMock(),
|
||||||
|
MagicMock(), MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
])
|
])
|
||||||
with patch("fabledassistant.services.trash.async_session") as cls:
|
with patch("fabledassistant.services.trash.async_session") as cls:
|
||||||
cls.return_value = session
|
cls.return_value = session
|
||||||
from fabledassistant.services.trash import delete
|
from fabledassistant.services.trash import delete
|
||||||
batch = await delete(user_id=1, entity_type="project", entity_id=3)
|
batch = await delete(user_id=1, entity_type="project", entity_id=3)
|
||||||
assert isinstance(batch, str)
|
assert isinstance(batch, str)
|
||||||
assert session.execute.await_count == 4
|
assert session.execute.await_count == 7
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user