Rules become findable: canon catalog, triggers, tiers, edges, retrieval, surfacing (milestone 307, steps 1–5) #131
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
|
||||
* map onto (milestone 307).
|
||||
*
|
||||
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
|
||||
* name the project gave it, and `canonical_id` only records which shared area
|
||||
* it is an instance of. An unmapped System is fully usable — the catalog is a
|
||||
* convergence aid, not a gate.
|
||||
*/
|
||||
import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client";
|
||||
|
||||
export interface CanonicalSystem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** The match key: lowercase, "&" folded to "and", punctuation collapsed. */
|
||||
slug: string;
|
||||
description: string | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested mapping. `basis` is the whole point of showing it:
|
||||
* - `exact` — the names differ only in spelling. Mechanical.
|
||||
* - `overlap` — they share a meaningful word. A judgment call the reviewer is
|
||||
* making, and it must never be presented as if it were the first.
|
||||
*/
|
||||
export interface CanonicalMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface MappingProposal {
|
||||
system_id: number;
|
||||
system_name: string;
|
||||
canonical_id: number;
|
||||
canonical_name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score: number;
|
||||
}
|
||||
|
||||
export async function listCanonicalSystems(): Promise<CanonicalSystem[]> {
|
||||
const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>(
|
||||
"/api/canonical-systems",
|
||||
);
|
||||
return data.canonical_systems;
|
||||
}
|
||||
|
||||
/** Admin only — a global list anyone can extend stops being shared. */
|
||||
export async function createCanonicalSystem(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<CanonicalSystem> {
|
||||
return apiPost("/api/canonical-systems", data);
|
||||
}
|
||||
|
||||
export async function updateCanonicalSystem(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
): Promise<CanonicalSystem> {
|
||||
return apiPatch(`/api/canonical-systems/${id}`, data);
|
||||
}
|
||||
|
||||
/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */
|
||||
export async function proposeMappings(projectId: number): Promise<MappingProposal[]> {
|
||||
const data = await apiGet<{ proposals: MappingProposal[] }>(
|
||||
`/api/projects/${projectId}/canonical-proposals`,
|
||||
);
|
||||
return data.proposals;
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping. `null` unmaps. */
|
||||
export async function mapSystem(
|
||||
systemId: number,
|
||||
canonicalId: number | null,
|
||||
): Promise<{ id: number; canonical_id: number | null }> {
|
||||
return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId });
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
|
||||
export interface System {
|
||||
id: number;
|
||||
project_id: number;
|
||||
name: string;
|
||||
/**
|
||||
* The global area this System is an instance of, or null. Null is a valid
|
||||
* resting state — a project-specific area should stay unmapped.
|
||||
*/
|
||||
canonical_id: number | null;
|
||||
description: string;
|
||||
color: string | null;
|
||||
status: "active" | "archived";
|
||||
@@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
||||
return data.systems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A created System, plus the catalog's answer about its name. An `exact`
|
||||
* catalog hit is applied by the server and arrives as a populated
|
||||
* `canonical_id`; an `overlap` is only OFFERED, and comes back here for the
|
||||
* caller to accept or ignore.
|
||||
*
|
||||
* A same-named System in this project is a 409 ApiError carrying
|
||||
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
|
||||
*/
|
||||
export interface CreatedSystem extends System {
|
||||
canonical_suggestion?: CanonicalMatch;
|
||||
}
|
||||
|
||||
export async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
): Promise<System> {
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
): Promise<CreatedSystem> {
|
||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { getProjectIssues } from "@/api/systems";
|
||||
import type { System, TaskLike } from "@/api/systems";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const store = useSystemsStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
@@ -19,14 +23,26 @@ const issues = ref<TaskLike[]>([]);
|
||||
const showCreate = ref(false);
|
||||
const newName = ref("");
|
||||
const newDescription = ref("");
|
||||
// The global area, chosen explicitly. A PICKER rather than a live matcher on
|
||||
// purpose: reproducing the server's slug rule in TypeScript would give this
|
||||
// feature two matchers to keep in step, which is the exact drift the catalog
|
||||
// exists to end. The server still applies an exact hit on submit.
|
||||
const newCanonicalId = ref<number | null>(null);
|
||||
const creating = ref(false);
|
||||
// An `overlap` the server offered after a create — an offer, never applied.
|
||||
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
|
||||
|
||||
// Edit state
|
||||
const editingId = ref<number | null>(null);
|
||||
const editName = ref("");
|
||||
const editDescription = ref("");
|
||||
const editCanonicalId = ref<number | null>(null);
|
||||
const savingEdit = ref(false);
|
||||
|
||||
// Mapping review
|
||||
const showReview = ref(false);
|
||||
const reviewBusy = ref<number | null>(null);
|
||||
|
||||
// Delete confirmation
|
||||
const deletingSystem = ref<System | null>(null);
|
||||
|
||||
@@ -37,6 +53,12 @@ const visibleSystems = computed(() =>
|
||||
showArchived.value ? systems.value : activeSystems.value,
|
||||
);
|
||||
|
||||
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
|
||||
|
||||
function areaName(system: System): string | null {
|
||||
return canon.byId(system.canonical_id)?.name ?? null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
error.value = null;
|
||||
try {
|
||||
@@ -49,6 +71,14 @@ async function load() {
|
||||
} catch {
|
||||
issues.value = [];
|
||||
}
|
||||
// Both fail soft: the catalog is a naming aid, and a review prompt that
|
||||
// cannot load must not take the Systems list down with it.
|
||||
await canon.fetchCatalog();
|
||||
try {
|
||||
await canon.fetchProposals(props.projectId);
|
||||
} catch {
|
||||
/* no proposals shown */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -58,12 +88,14 @@ function openCreate() {
|
||||
showCreate.value = true;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
showCreate.value = false;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
@@ -71,23 +103,60 @@ async function submitCreate() {
|
||||
if (!name || creating.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await store.createSystem(props.projectId, {
|
||||
const created = await store.createSystem(props.projectId, {
|
||||
name,
|
||||
description: newDescription.value.trim() || undefined,
|
||||
canonical_id: newCanonicalId.value ?? undefined,
|
||||
});
|
||||
cancelCreate();
|
||||
toast.show("System created");
|
||||
} catch {
|
||||
toast.show("Failed to create system", "error");
|
||||
if (created.canonical_suggestion) {
|
||||
// An overlap: shown as an offer beside the new System, never applied.
|
||||
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
|
||||
}
|
||||
toast.show(
|
||||
created.canonical_id
|
||||
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
|
||||
: "System created",
|
||||
);
|
||||
} catch (e) {
|
||||
// 409 = this project already has that System. Say WHICH one, so the
|
||||
// answer is actionable rather than "it didn't work".
|
||||
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSuggestion() {
|
||||
const pending = suggestion.value;
|
||||
if (!pending) return;
|
||||
suggestion.value = null;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
|
||||
await store.fetchSystems(props.projectId);
|
||||
toast.show(`Filed under ${pending.match.name}`);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProposal(systemId: number, canonicalId: number) {
|
||||
reviewBusy.value = systemId;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, systemId, canonicalId);
|
||||
await store.fetchSystems(props.projectId);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
} finally {
|
||||
reviewBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(system: System) {
|
||||
editingId.value = system.id;
|
||||
editName.value = system.name;
|
||||
editDescription.value = system.description;
|
||||
editCanonicalId.value = system.canonical_id;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -103,6 +172,12 @@ async function submitEdit(system: System) {
|
||||
name,
|
||||
description: editDescription.value.trim(),
|
||||
});
|
||||
// The mapping is a separate write with its own validation — one column,
|
||||
// one writer (services/canonical_systems.set_system_canonical).
|
||||
if (editCanonicalId.value !== system.canonical_id) {
|
||||
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
|
||||
await store.fetchSystems(props.projectId);
|
||||
}
|
||||
editingId.value = null;
|
||||
toast.show("System updated");
|
||||
} catch {
|
||||
@@ -161,6 +236,65 @@ async function confirmDelete() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Mapping review. Only appears when there is something to decide, and
|
||||
it says HOW MANY rather than nagging with a permanent banner. -->
|
||||
<div v-if="proposals.length" class="area-review">
|
||||
<button class="area-review-head" @click="showReview = !showReview">
|
||||
<span class="area-review-count">{{ proposals.length }}</span>
|
||||
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
|
||||
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<ul v-if="showReview" class="area-proposals">
|
||||
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
|
||||
<div class="area-proposal-text">
|
||||
<span class="area-proposal-name">{{ p.system_name }}</span>
|
||||
<span class="area-proposal-arrow" aria-hidden="true">→</span>
|
||||
<span class="area-proposal-target">{{ p.canonical_name }}</span>
|
||||
<!-- The basis is the decision the reviewer is making: `exact`
|
||||
differs only in spelling, `overlap` is a judgment call.
|
||||
Showing them identically is how a wrong mapping is waved
|
||||
through, so they never share a style. -->
|
||||
<span
|
||||
class="area-basis"
|
||||
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
|
||||
:title="
|
||||
p.basis === 'exact'
|
||||
? 'Same name up to spelling — safe to accept.'
|
||||
: 'Shares a word. Accept only if it is really the same area.'
|
||||
"
|
||||
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
|
||||
</div>
|
||||
<div class="area-proposal-actions">
|
||||
<button
|
||||
class="btn-primary btn-compact"
|
||||
:disabled="reviewBusy === p.system_id"
|
||||
@click="applyProposal(p.system_id, p.canonical_id)"
|
||||
>
|
||||
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="canon.dismissProposal(props.projectId, p.system_id)"
|
||||
>
|
||||
Not this
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- An overlap offered by the server after a create. Never applied. -->
|
||||
<div v-if="suggestion" class="area-offer">
|
||||
<span>
|
||||
Is this the same area as
|
||||
<strong>{{ suggestion.match.name }}</strong>?
|
||||
</span>
|
||||
<div class="area-proposal-actions">
|
||||
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
|
||||
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
@@ -189,6 +323,20 @@ async function confirmDelete() {
|
||||
placeholder="What is this subsystem responsible for? (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- .field-hint is the shared hint class beside .fs-input
|
||||
(components.css) — not restated scoped. -->
|
||||
<span class="field-hint">
|
||||
Files this system under an area shared by every project. Your name stays as you typed it.
|
||||
</span>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
@@ -240,6 +388,15 @@ async function confirmDelete() {
|
||||
placeholder="Description (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
@@ -264,6 +421,13 @@ async function confirmDelete() {
|
||||
:title="`${system.open_issue_count} open issue(s)`"
|
||||
>{{ system.open_issue_count }} open</span>
|
||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
|
||||
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
|
||||
This is the shared AREA this system is an instance of. -->
|
||||
<span
|
||||
v-if="areaName(system)"
|
||||
class="area-chip"
|
||||
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
|
||||
>{{ areaName(system) }}</span>
|
||||
</div>
|
||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||
</div>
|
||||
@@ -335,6 +499,91 @@ async function confirmDelete() {
|
||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Shared-area mapping (milestone 307) ──────────────────────────
|
||||
The review is a disclosure, not a banner: it exists only while there is
|
||||
something to decide, and collapses to one line until opened. */
|
||||
.area-review {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.area-review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.area-review-head:hover { color: var(--fs-text-primary); }
|
||||
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.area-review-count {
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
|
||||
|
||||
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-proposal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
|
||||
.area-proposal-name { color: var(--fs-text-primary); }
|
||||
.area-proposal-arrow { color: var(--fs-text-tertiary); }
|
||||
.area-proposal-target { color: var(--fs-accent); }
|
||||
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
|
||||
|
||||
/* The two bases must never look alike — one is mechanical, the other is the
|
||||
reviewer's judgment, and that difference is the whole decision. */
|
||||
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
|
||||
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
|
||||
.area-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-3);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-accent-faint);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
.area-select { box-sizing: border-box; width: 100%; }
|
||||
|
||||
.area-chip {
|
||||
font-size: 0.66rem;
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.btn-add-system {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import * as api from "@/api/canonicalSystems";
|
||||
import type { CanonicalSystem, MappingProposal } from "@/api/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
/**
|
||||
* The global area catalog (milestone 307). Shared by every project, so it is
|
||||
* fetched ONCE per session rather than per project — the whole point of the
|
||||
* table is that it is the same list everywhere.
|
||||
*/
|
||||
export const useCanonicalSystemsStore = defineStore("canonicalSystems", () => {
|
||||
const catalog = ref<CanonicalSystem[]>([]);
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
const proposalsByProject = ref<Record<number, MappingProposal[]>>({});
|
||||
|
||||
async function fetchCatalog(force = false) {
|
||||
if (loaded.value && !force) return catalog.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
catalog.value = await api.listCanonicalSystems();
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
// A naming aid must never break the screen it rides on — an empty
|
||||
// catalog degrades the suggestion, it does not fail the form.
|
||||
catalog.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return catalog.value;
|
||||
}
|
||||
|
||||
function byId(id: number | null): CanonicalSystem | undefined {
|
||||
if (id == null) return undefined;
|
||||
return catalog.value.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
async function fetchProposals(projectId: number) {
|
||||
proposalsByProject.value[projectId] = await api.proposeMappings(projectId);
|
||||
return proposalsByProject.value[projectId];
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping, then drop it from the pending proposals. */
|
||||
async function mapSystem(projectId: number, systemId: number, canonicalId: number | null) {
|
||||
try {
|
||||
await api.mapSystem(systemId, canonicalId);
|
||||
} catch (e) {
|
||||
useToastStore().show(apiErrorMessage(e, "Failed to map system"), "error");
|
||||
throw e;
|
||||
}
|
||||
dismissProposal(projectId, systemId);
|
||||
}
|
||||
|
||||
/** Remove a proposal from the pending list without writing anything. */
|
||||
function dismissProposal(projectId: number, systemId: number) {
|
||||
const list = proposalsByProject.value[projectId];
|
||||
if (list) {
|
||||
proposalsByProject.value[projectId] = list.filter((p) => p.system_id !== systemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntry(data: { name: string; description?: string }) {
|
||||
const entry = await api.createCanonicalSystem(data);
|
||||
catalog.value.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function updateEntry(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
) {
|
||||
const entry = await api.updateCanonicalSystem(id, data);
|
||||
const idx = catalog.value.findIndex((c) => c.id === id);
|
||||
if (idx >= 0) catalog.value[idx] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
loaded,
|
||||
loading,
|
||||
proposalsByProject,
|
||||
fetchCatalog,
|
||||
byId,
|
||||
fetchProposals,
|
||||
mapSystem,
|
||||
dismissProposal,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
};
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
||||
|
||||
async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
) {
|
||||
const system = await api.createSystem(projectId, data);
|
||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -12,6 +13,62 @@ import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
|
||||
// ── Shared areas (milestone 307) ────────────────────────────────────────
|
||||
// The global vocabulary a project's Systems map onto. Admin-only to WRITE —
|
||||
// a global list anyone can extend stops being a shared list — but every user
|
||||
// reads it, which is why the catalog lives in a store rather than here.
|
||||
const canonStore = useCanonicalSystemsStore();
|
||||
const newAreaName = ref("");
|
||||
const newAreaDescription = ref("");
|
||||
const creatingArea = ref(false);
|
||||
const editingAreaId = ref<number | null>(null);
|
||||
const editAreaName = ref("");
|
||||
const editAreaDescription = ref("");
|
||||
const savingArea = ref(false);
|
||||
|
||||
async function createArea() {
|
||||
const name = newAreaName.value.trim();
|
||||
if (!name || creatingArea.value) return;
|
||||
creatingArea.value = true;
|
||||
try {
|
||||
await canonStore.createEntry({
|
||||
name,
|
||||
description: newAreaDescription.value.trim() || undefined,
|
||||
});
|
||||
newAreaName.value = "";
|
||||
newAreaDescription.value = "";
|
||||
toastStore.show("Area added");
|
||||
} catch (e) {
|
||||
// A 409 means an area with the same match key already exists — say which,
|
||||
// because "CI and Release" vs "CI & Release" looks like a different name.
|
||||
toastStore.show(apiErrorMessage(e, "Failed to add area"), "error");
|
||||
} finally {
|
||||
creatingArea.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditArea(id: number, name: string, description: string | null) {
|
||||
editingAreaId.value = id;
|
||||
editAreaName.value = name;
|
||||
editAreaDescription.value = description ?? "";
|
||||
}
|
||||
|
||||
async function saveArea() {
|
||||
const id = editingAreaId.value;
|
||||
const name = editAreaName.value.trim();
|
||||
if (id == null || !name || savingArea.value) return;
|
||||
savingArea.value = true;
|
||||
try {
|
||||
await canonStore.updateEntry(id, { name, description: editAreaDescription.value.trim() });
|
||||
editingAreaId.value = null;
|
||||
toastStore.show("Area updated");
|
||||
} catch (e) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to update area"), "error");
|
||||
} finally {
|
||||
savingArea.value = false;
|
||||
}
|
||||
}
|
||||
const userTimezone = ref("");
|
||||
const savingTimezone = ref(false);
|
||||
const timezoneSaved = ref(false);
|
||||
@@ -134,7 +191,7 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -143,6 +200,7 @@ function _loadTabContent(tab: string) {
|
||||
if (tab === "users") loadUsersPanel();
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||
}
|
||||
if (tab === "apikeys") { fetchApiKeys(); }
|
||||
}
|
||||
@@ -1212,7 +1270,7 @@ async function deleteUser(userId: number) {
|
||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||
<div class="sidebar-group-label">Admin</div>
|
||||
<button
|
||||
v-for="tab in ['config', 'users', 'groups', 'logs']"
|
||||
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -2263,6 +2321,78 @@ async function deleteUser(userId: number) {
|
||||
</div>
|
||||
|
||||
<!-- ── Users ── -->
|
||||
<!-- ── Shared areas ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'areas'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Shared areas</h2>
|
||||
<p class="field-hint">
|
||||
The vocabulary every project's Systems can be filed under, so the same word means the
|
||||
same thing everywhere. A project keeps its own name for an area — mapping records which
|
||||
shared area it is, it never renames anything. Editing a name here re-derives its match
|
||||
key, so existing mappings are kept but future name matching follows the new spelling.
|
||||
</p>
|
||||
|
||||
<ul class="area-admin-list">
|
||||
<li v-for="entry in canonStore.catalog" :key="entry.id" class="area-admin-row">
|
||||
<template v-if="editingAreaId === entry.id">
|
||||
<form class="area-admin-form" @submit.prevent="saveArea">
|
||||
<input v-model="editAreaName" class="fs-input" aria-label="Area name" />
|
||||
<textarea
|
||||
v-model="editAreaDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
aria-label="Area description"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editAreaName.trim() || savingArea">
|
||||
{{ savingArea ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="editingAreaId = null">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="area-admin-body">
|
||||
<div class="area-admin-name-row">
|
||||
<span class="area-admin-name">{{ entry.name }}</span>
|
||||
<code class="area-admin-slug" title="The match key. Names that reduce to this are the same area.">{{ entry.slug }}</code>
|
||||
</div>
|
||||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="startEditArea(entry.id, entry.name, entry.description)"
|
||||
>Edit</button>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!canonStore.catalog.length && !canonStore.loading" class="settings-empty">
|
||||
No areas yet.
|
||||
</p>
|
||||
|
||||
<form class="area-admin-form area-admin-create" @submit.prevent="createArea">
|
||||
<input
|
||||
v-model="newAreaName"
|
||||
class="fs-input"
|
||||
placeholder="New area name (e.g. Search & Indexing)"
|
||||
aria-label="New area name"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newAreaDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
placeholder="What belongs in this area? One paragraph — a bare name is never enough."
|
||||
aria-label="New area description"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newAreaName.trim() || creatingArea">
|
||||
{{ creatingArea ? "Adding…" : "Add area" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
@@ -3401,4 +3531,34 @@ async function deleteUser(userId: number) {
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Shared areas (milestone 307) ──────────────────────────────────
|
||||
The slug is shown deliberately: it is what decides whether two names are
|
||||
the same area, and an admin renaming an entry needs to see it move. */
|
||||
.area-admin-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-admin-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
padding: var(--fs-space-3);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-admin-body { flex: 1; min-width: 0; }
|
||||
.area-admin-name-row { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.area-admin-name { color: var(--fs-text-primary); }
|
||||
.area-admin-slug {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.area-admin-desc { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||||
.area-admin-create { margin-top: var(--fs-space-4); }
|
||||
.area-admin-actions { display: flex; gap: 0.4rem; }
|
||||
</style>
|
||||
|
||||
@@ -197,36 +197,60 @@ async def create_system(
|
||||
normalized name already exists in this project (archived included), the
|
||||
call returns {"duplicate": true, "existing_id": ...} instead of creating —
|
||||
tag records to that one, or update_system it if its charter needs work.
|
||||
|
||||
Also mapped against the GLOBAL area catalog, so the same word means the
|
||||
same thing in every project (milestone 307). A name that IS a catalog area
|
||||
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
|
||||
the response says so. A name that merely RESEMBLES one comes back with
|
||||
`canonical_suggestion` — an offer, not a decision: apply it with
|
||||
map_system_to_canonical if it really is that area, ignore it if this is a
|
||||
project-specific area. Either way the System is created; the catalog never
|
||||
blocks a name.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
norm = " ".join(name.split()).lower()
|
||||
if norm:
|
||||
try:
|
||||
existing = await systems_svc.list_systems(
|
||||
uid, project_id, include_archived=True
|
||||
)
|
||||
except Exception:
|
||||
existing = []
|
||||
for s in existing:
|
||||
if " ".join(s.name.split()).lower() == norm:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": s.id,
|
||||
"message": (
|
||||
f"System '{s.name}' (#{s.id}) already covers this area "
|
||||
"in this project. Tag records to it with system_ids, "
|
||||
"or update_system it if the charter needs revising — "
|
||||
"a second System with the same name would split the "
|
||||
"area's records across two piles."
|
||||
),
|
||||
}
|
||||
assessment = await systems_svc.assess_system_name(uid, project_id, name)
|
||||
duplicate = assessment["duplicate"]
|
||||
if duplicate:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": duplicate["id"],
|
||||
"message": (
|
||||
f"System '{duplicate['name']}' (#{duplicate['id']}) already "
|
||||
"covers this area in this project. Tag records to it with "
|
||||
"system_ids, or update_system it if the charter needs "
|
||||
"revising — a second System with the same name would split "
|
||||
"the area's records across two piles."
|
||||
),
|
||||
}
|
||||
# An exact match is mechanical, so it is applied; an overlap is a judgment
|
||||
# call, so it is only offered (see services/canonical_systems).
|
||||
canonical = assessment["canonical"]
|
||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||
system = await systems_svc.create_system(
|
||||
uid, project_id=project_id, name=name,
|
||||
description=description or None, color=color or None,
|
||||
canonical_id=applied,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
||||
return system.to_dict()
|
||||
out = system.to_dict()
|
||||
if applied:
|
||||
out["canonical_note"] = (
|
||||
f"Mapped to the global area '{canonical['name']}' — the same "
|
||||
"spelling-insensitive name. Your System keeps the name you gave it."
|
||||
)
|
||||
elif canonical:
|
||||
out["canonical_suggestion"] = {
|
||||
**canonical,
|
||||
"message": (
|
||||
f"The global catalog has '{canonical['name']}', which may be "
|
||||
f"this same area. If it is, map_system_to_canonical("
|
||||
f"{system.id}, {canonical['id']}) so records and rules about "
|
||||
"this area line up across projects. If this area is specific "
|
||||
"to this project, ignore it — unmapped is a valid state."
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
|
||||
|
||||
@@ -62,14 +62,38 @@ async def create_system_route(project_id: int):
|
||||
data = await request.get_json() or {}
|
||||
if not (data.get("name") or "").strip():
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
# The same gate the MCP door enforces. It lived only in the tool layer
|
||||
# until now, which is exactly how the web UI shipped without gates the
|
||||
# agent surface had (#2482) — one service call, one answer (rule 33).
|
||||
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
|
||||
duplicate = assessment["duplicate"]
|
||||
if duplicate and not data.get("force"):
|
||||
return jsonify({
|
||||
"duplicate": True,
|
||||
"existing_id": duplicate["id"],
|
||||
"error": (
|
||||
f"“{duplicate['name']}” already covers this area in this "
|
||||
"project. Tag records to it, or rename it if its charter has "
|
||||
"moved on — a second System with the same name splits the "
|
||||
"area's records across two piles."
|
||||
),
|
||||
}), 409
|
||||
canonical = assessment["canonical"]
|
||||
# Exact is mechanical and applied; overlap is a judgment call and is only
|
||||
# offered back for the form to present.
|
||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||
system = await systems_svc.create_system(
|
||||
uid, project_id=project_id, name=data["name"],
|
||||
description=data.get("description"), color=data.get("color"),
|
||||
order_index=data.get("order_index", 0),
|
||||
canonical_id=data.get("canonical_id") or applied,
|
||||
)
|
||||
if system is None:
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
return jsonify(system.to_dict()), 201
|
||||
out = system.to_dict()
|
||||
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
|
||||
out["canonical_suggestion"] = canonical
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
|
||||
|
||||
@@ -96,6 +96,47 @@ async def find_by_name(name: str) -> CanonicalSystem | None:
|
||||
)
|
||||
|
||||
|
||||
def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
|
||||
return len(local & other) / max(len(local | other), 1)
|
||||
|
||||
|
||||
async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
|
||||
"""The closest catalog entry that shares a meaningful word, or None.
|
||||
|
||||
The ONE scorer behind both offers: the create-time suggestion and the
|
||||
review surface. Two scorers would eventually disagree about which area a
|
||||
name resembles, and the operator would be asked one question at create
|
||||
time and a different one at review.
|
||||
|
||||
The threshold is any shared meaningful word, deliberately generous: a
|
||||
wrong offer costs one dismissal, a missing one costs a mapping nobody
|
||||
thinks to make again. Nothing here ever applies — `overlap` is always an
|
||||
offer (see propose_mappings).
|
||||
"""
|
||||
slug = canonical_slug(name)
|
||||
if not slug:
|
||||
return None
|
||||
local = _tokens(slug)
|
||||
if not local:
|
||||
return None
|
||||
# A caller already holding the catalog passes it: this runs once per
|
||||
# unmapped System in the review sweep, and re-reading the table each time
|
||||
# would make an N+1 out of a report.
|
||||
if catalog is None:
|
||||
catalog = await list_canonical_systems()
|
||||
best, best_score = None, 0.0
|
||||
for entry in catalog:
|
||||
score = _overlap(local, _tokens(entry.slug))
|
||||
if score > best_score:
|
||||
best, best_score = entry, score
|
||||
if best is None or best_score <= 0:
|
||||
return None
|
||||
return {
|
||||
"id": best.id, "name": best.name,
|
||||
"basis": "overlap", "score": round(best_score, 3),
|
||||
}
|
||||
|
||||
|
||||
async def create_canonical_system(
|
||||
user_id: int, name: str, description: str | None = None,
|
||||
) -> CanonicalSystem | dict | None:
|
||||
@@ -230,27 +271,20 @@ async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
|
||||
continue
|
||||
exact = by_slug.get(slug)
|
||||
if exact is not None:
|
||||
match, basis, score = exact, "exact", 1.0
|
||||
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
|
||||
else:
|
||||
local = _tokens(slug)
|
||||
scored = [
|
||||
(len(local & _tokens(entry.slug)) / max(len(local | _tokens(entry.slug)), 1), entry)
|
||||
for entry in catalog
|
||||
]
|
||||
# Any shared meaningful word is enough to ASK. The threshold is
|
||||
# deliberately generous because a wrong proposal costs one click
|
||||
# and a missing one costs a mapping nobody thinks to make again.
|
||||
best_score, best = max(scored, key=lambda pair: pair[0])
|
||||
if best_score <= 0:
|
||||
# Same scorer the create-time offer uses, so the two surfaces can
|
||||
# never name different areas for one System.
|
||||
match = await best_overlap(system.name, catalog)
|
||||
if match is None:
|
||||
continue
|
||||
match, basis, score = best, "overlap", round(best_score, 3)
|
||||
proposals.append({
|
||||
"system_id": system.id,
|
||||
"system_name": system.name,
|
||||
"canonical_id": match.id,
|
||||
"canonical_name": match.name,
|
||||
"basis": basis,
|
||||
"score": score,
|
||||
"canonical_id": match["id"],
|
||||
"canonical_name": match["name"],
|
||||
"basis": match["basis"],
|
||||
"score": match["score"],
|
||||
})
|
||||
proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
|
||||
return proposals
|
||||
|
||||
@@ -19,6 +19,66 @@ from scribe.services import canonical_systems as canonical_systems_svc
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def local_name_key(name: str) -> str:
|
||||
"""The within-project uniqueness key: case and spacing, nothing else.
|
||||
|
||||
Deliberately weaker than `canonical_slug`. This one answers "is this the
|
||||
same System I already have here", where the operator's own spelling is the
|
||||
thing being compared; the canonical slug answers "is this the same AREA as
|
||||
some other project's System", where spelling is exactly what must be
|
||||
ignored.
|
||||
"""
|
||||
return " ".join(name.split()).lower()
|
||||
|
||||
|
||||
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
||||
"""What BOTH doors must know before minting a System name (milestone 307).
|
||||
|
||||
Lived in the MCP tool alone until now, which is how the web UI shipped
|
||||
without a gate the agent surface enforced (#2482). One service function, so
|
||||
the two doors cannot answer the same question differently (rule 33).
|
||||
|
||||
Returns `{"duplicate": …|None, "canonical": …|None}`:
|
||||
|
||||
- `duplicate` — this project already has a System by that name. A hard stop
|
||||
for the caller: a second one splits the area's records across two piles.
|
||||
- `canonical` — the global catalog covers this area, with a `basis`.
|
||||
`exact` is mechanical and safe to apply on the spot; `overlap` is a
|
||||
judgment call and must be OFFERED, never applied. Neither ever blocks:
|
||||
an unmatched name is a project-specific area, which is legitimate.
|
||||
|
||||
Fails open on both arms — a naming aid must never break a create.
|
||||
"""
|
||||
out: dict = {"duplicate": None, "canonical": None}
|
||||
key = local_name_key(name)
|
||||
if not key:
|
||||
return out
|
||||
try:
|
||||
for existing in await list_systems(user_id, project_id, include_archived=True):
|
||||
if local_name_key(existing.name) == key:
|
||||
out["duplicate"] = {"id": existing.id, "name": existing.name}
|
||||
return out
|
||||
except Exception:
|
||||
logger.debug("system name assessment: local scan failed", exc_info=True)
|
||||
return out
|
||||
try:
|
||||
exact = await canonical_systems_svc.find_by_name(name)
|
||||
if exact is not None:
|
||||
out["canonical"] = {
|
||||
"id": exact.id, "name": exact.name, "basis": "exact",
|
||||
}
|
||||
return out
|
||||
# No exact hit: fall back to the same overlap scoring the review
|
||||
# surface uses, so a create-time offer and a later proposal never
|
||||
# disagree about which area a name resembles.
|
||||
near = await canonical_systems_svc.best_overlap(name)
|
||||
if near is not None:
|
||||
out["canonical"] = near
|
||||
except Exception:
|
||||
logger.debug("system name assessment: catalog lookup failed", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
async def standard_systems() -> list[tuple[str, str]]:
|
||||
"""The standard cross-project vocabulary (#2798) as (name, charter) pairs.
|
||||
|
||||
|
||||
+3
-1
@@ -132,7 +132,9 @@ def fake_milestone(**attrs) -> MagicMock:
|
||||
|
||||
|
||||
def fake_system(**attrs) -> MagicMock:
|
||||
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
|
||||
return _with_defaults(
|
||||
{"id": 1, "name": "Reader", "project_id": 5, "canonical_id": None}, attrs,
|
||||
)
|
||||
|
||||
|
||||
def fake_rulebook(**attrs) -> MagicMock:
|
||||
|
||||
@@ -5,26 +5,86 @@ import pytest
|
||||
from tests.helpers import fake_note, fake_system
|
||||
|
||||
|
||||
# The name assessment both doors run before minting (milestone 307). A test
|
||||
# that patches systems_svc wholesale must stub it, or the awaited MagicMock
|
||||
# raises — this shape is the "nothing matched" answer.
|
||||
_NO_MATCH = {"duplicate": None, "canonical": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_returns_dict():
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
|
||||
svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
result = await create_system(project_id=5, name="Reader", description="pdf reader")
|
||||
assert result["name"] == "Reader"
|
||||
# An unmatched name is a project-specific area: created, no offer, no fuss.
|
||||
assert "canonical_suggestion" not in result and "canonical_note" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_no_access_raises():
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
|
||||
svc.create_system = AsyncMock(return_value=None)
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
with pytest.raises(ValueError):
|
||||
await create_system(project_id=5, name="Reader")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
|
||||
"""The two bases must behave differently, and this is where it is decided.
|
||||
|
||||
`exact` differs from the catalog name only in spelling, so it is APPLIED —
|
||||
that is the mechanical case the catalog exists to collapse. `overlap` is a
|
||||
judgment call, so it is only OFFERED: applying it silently is how a
|
||||
cross-project rule ends up surfacing in the wrong project.
|
||||
"""
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value={
|
||||
"duplicate": None,
|
||||
"canonical": {"id": 3, "name": "CI & Release", "basis": "exact"},
|
||||
})
|
||||
svc.create_system = AsyncMock(return_value=fake_system(name="CI and Release"))
|
||||
exact = await create_system(project_id=5, name="CI and Release")
|
||||
assert svc.create_system.await_args.kwargs["canonical_id"] == 3
|
||||
assert "canonical_note" in exact and "canonical_suggestion" not in exact
|
||||
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value={
|
||||
"duplicate": None,
|
||||
"canonical": {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33},
|
||||
})
|
||||
svc.create_system = AsyncMock(return_value=fake_system(id=9, name="CI & runners"))
|
||||
similar = await create_system(project_id=5, name="CI & runners")
|
||||
assert svc.create_system.await_args.kwargs["canonical_id"] is None
|
||||
assert similar["canonical_suggestion"]["id"] == 3
|
||||
assert "map_system_to_canonical(9, 3)" in similar["canonical_suggestion"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_system_duplicate_names_the_existing_one_and_creates_nothing():
|
||||
from scribe.mcp.tools.systems import create_system
|
||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.assess_system_name = AsyncMock(return_value={
|
||||
"duplicate": {"id": 4, "name": "Reader"}, "canonical": None,
|
||||
})
|
||||
svc.create_system = AsyncMock()
|
||||
result = await create_system(project_id=5, name="reader")
|
||||
assert result["duplicate"] is True and result["existing_id"] == 4
|
||||
assert "Reader" in result["message"]
|
||||
svc.create_system.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_system_splits_records_by_kind():
|
||||
issue = MagicMock(); issue.to_dict.return_value = {"id": 10}; issue.task_kind = "issue"; issue.status = "todo"
|
||||
|
||||
Reference in New Issue
Block a user