Skills and processes declare their moments, and the UI for moments (milestone 458 steps 5–6) #202
@@ -0,0 +1,101 @@
|
|||||||
|
import { apiGet, apiPost, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moments of work rules mount on (milestone 458), and the actions that
|
||||||
|
* reach each one on this install — `GET /api/retrieval/moments`, the payload
|
||||||
|
* the `list_moments` MCP tool returns, plus the counts the Settings view shows
|
||||||
|
* beside each moment.
|
||||||
|
*/
|
||||||
|
export interface Moment {
|
||||||
|
name: string;
|
||||||
|
/** What is happening at this moment. */
|
||||||
|
means: string;
|
||||||
|
/** The kinds of action that typically reach it. */
|
||||||
|
reached_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shape rather than an entry: `skill.<name>`, one moment per procedure. */
|
||||||
|
export interface MomentFamily {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
means: string;
|
||||||
|
reached_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `default` ships with the product; `install` is this install's own. */
|
||||||
|
export type ActionVia = "default" | "install";
|
||||||
|
|
||||||
|
export interface MomentAction {
|
||||||
|
tool: string;
|
||||||
|
/** Empty = every call of the tool. A command prefix for a command tool,
|
||||||
|
* `field=value` pairs for any other. */
|
||||||
|
match: string;
|
||||||
|
via: ActionVia;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shipped default this install switched off. */
|
||||||
|
export interface RemovedDefault {
|
||||||
|
id: number;
|
||||||
|
tool: string;
|
||||||
|
match: string;
|
||||||
|
moment: string;
|
||||||
|
reason: string | null;
|
||||||
|
actor: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MomentUsage {
|
||||||
|
/** How many times a mounted rule arrived at this moment. */
|
||||||
|
delivered: number;
|
||||||
|
/** How many distinct rules did. */
|
||||||
|
rules: number;
|
||||||
|
/** Of those, how many an agent then opened — an upper bound: a pull records
|
||||||
|
* the door, not the line that prompted it. */
|
||||||
|
opened: number;
|
||||||
|
last_delivered_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MomentsPayload {
|
||||||
|
moments: Moment[];
|
||||||
|
families: MomentFamily[];
|
||||||
|
total: number;
|
||||||
|
actions: Record<string, MomentAction[]>;
|
||||||
|
removed_defaults: RemovedDefault[];
|
||||||
|
/** Rules mounted per moment; a moment carrying none is absent. */
|
||||||
|
mounted: Record<string, number>;
|
||||||
|
/** The count read failed — `mounted` is empty for that reason, not because
|
||||||
|
* nothing is mounted. */
|
||||||
|
mounted_failed?: boolean;
|
||||||
|
usage: {
|
||||||
|
by_moment: Record<string, MomentUsage>;
|
||||||
|
days: number;
|
||||||
|
/** Same distinction as `mounted_failed`, for the usage read. */
|
||||||
|
moment_usage_failed?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MappingChange {
|
||||||
|
tool: string;
|
||||||
|
match?: string;
|
||||||
|
moment: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMoments(days = 30): Promise<MomentsPayload> {
|
||||||
|
return apiGet(`/api/retrieval/moments?days=${days}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAction(change: MappingChange): Promise<unknown> {
|
||||||
|
return apiPost("/api/retrieval/moments/mappings", change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query parameters, not a body: DELETE bodies are not reliably sent. */
|
||||||
|
export function unmapAction(change: MappingChange): Promise<void> {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
tool: change.tool,
|
||||||
|
match: change.match ?? "",
|
||||||
|
moment: change.moment,
|
||||||
|
reason: change.reason ?? "",
|
||||||
|
});
|
||||||
|
return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`);
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ export interface Rule {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
/** Present only when the rule has them (the server omits empty keys). */
|
/** Present only when the rule has them (the server omits empty keys). */
|
||||||
systems?: { id: number; name: string }[];
|
systems?: { id: number; name: string }[];
|
||||||
|
/** The moments this rule arrives at whenever they happen (milestone 458),
|
||||||
|
* in catalog order. Present only when the rule is mounted. */
|
||||||
|
moments?: string[];
|
||||||
relations?: RuleRelation[];
|
relations?: RuleRelation[];
|
||||||
/** The lessons that point at this rule (milestone 440) — the concrete
|
/** The lessons that point at this rule (milestone 440) — the concrete
|
||||||
* situations judged instances of it, plus any suggested and awaiting a
|
* situations judged instances of it, plus any suggested and awaiting a
|
||||||
@@ -210,6 +213,8 @@ export interface RuleWrite {
|
|||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
system_ids: number[];
|
system_ids: number[];
|
||||||
|
/** Replaces the set; [] unmounts the rule from every moment. */
|
||||||
|
moments: string[];
|
||||||
arose_from_id: number | null;
|
arose_from_id: number | null;
|
||||||
verify_with: string;
|
verify_with: string;
|
||||||
expires_when: string;
|
expires_when: string;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/* Moments (milestone 458): the pieces the rule editor's picker and the
|
||||||
|
Settings section share, so a moment's name and a chip's remove control look
|
||||||
|
the same wherever a person meets them. Loaded unscoped by both. */
|
||||||
|
|
||||||
|
/* The name as a session reads it in a delivered line — shown as written. */
|
||||||
|
.moment-name {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The × on a removable chip: quiet until hovered, and keyboard-reachable. */
|
||||||
|
.chip-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.chip-remove:hover:not(:disabled) { color: var(--fs-text-primary); }
|
||||||
|
.chip-remove:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||||
|
.chip-remove:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--fs-focus-ring);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useMomentsStore } from "@/stores/moments";
|
||||||
|
import type { MomentAction, MomentUsage } from "@/api/moments";
|
||||||
|
import { fmtDate } from "@/utils/dateFormat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The human door onto moments (milestone 458 step 6): every moment in the
|
||||||
|
* catalog, the actions that reach it on this install, how many rules ride on
|
||||||
|
* it, and what reaching it has delivered. The session's own door is
|
||||||
|
* `list_moments` / `map_action` / `unmap_action`, and both call the same
|
||||||
|
* services — this view exists so a person can see and change everything an
|
||||||
|
* agent can, without asking one to.
|
||||||
|
*/
|
||||||
|
const store = useMomentsStore();
|
||||||
|
|
||||||
|
const data = computed(() => store.data);
|
||||||
|
const catalog = computed(() => data.value?.moments ?? []);
|
||||||
|
const days = computed(() => data.value?.usage.days ?? 30);
|
||||||
|
const usageFailed = computed(() => !!data.value?.usage.moment_usage_failed);
|
||||||
|
|
||||||
|
function actionsFor(moment: string): MomentAction[] {
|
||||||
|
return data.value?.actions[moment] ?? [];
|
||||||
|
}
|
||||||
|
function mounted(moment: string): number {
|
||||||
|
return data.value?.mounted[moment] ?? 0;
|
||||||
|
}
|
||||||
|
function usage(moment: string): MomentUsage | null {
|
||||||
|
return data.value?.usage.by_moment[moment] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A named procedure is its own moment (`skill.<name>`), so it appears here
|
||||||
|
// only once something is mounted on it or has been delivered at it — the
|
||||||
|
// catalog cannot list procedures it has never heard of.
|
||||||
|
const procedureMoments = computed(() => {
|
||||||
|
const names = new Set<string>([
|
||||||
|
...Object.keys(data.value?.mounted ?? {}),
|
||||||
|
...Object.keys(data.value?.usage.by_moment ?? {}),
|
||||||
|
]);
|
||||||
|
return [...names].filter((n) => n.startsWith("skill.")).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
function actionLabel(a: { tool: string; match: string }): string {
|
||||||
|
return a.match ? `${a.tool} · ${a.match}` : a.tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<unknown>) {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch {
|
||||||
|
// The store has already said why; the row stays as it was.
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAction(moment: string, a: MomentAction) {
|
||||||
|
// A shipped default is switched off rather than deleted, and comes back
|
||||||
|
// with "Restore" below — worth a confirm, since every later session on
|
||||||
|
// this install loses the moment for that action.
|
||||||
|
if (a.via === "default"
|
||||||
|
&& !confirm(`Stop ${actionLabel(a)} reaching ${moment} on this install? You can restore it below.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void run(() => store.unmap({ tool: a.tool, match: a.match, moment, reason: "Removed in Settings." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(r: { tool: string; match: string; moment: string }) {
|
||||||
|
void run(() => store.map({ tool: r.tool, match: r.match, moment: r.moment }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add a mapping ─────────────────────────────────────────────────────────
|
||||||
|
const draftTool = ref("");
|
||||||
|
const draftMatch = ref("");
|
||||||
|
const draftMoment = ref("");
|
||||||
|
const draftReason = ref("");
|
||||||
|
const canAdd = computed(() => !!draftTool.value.trim() && !!draftMoment.value && !busy.value);
|
||||||
|
|
||||||
|
async function addMapping() {
|
||||||
|
if (!canAdd.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await store.map({
|
||||||
|
tool: draftTool.value.trim(),
|
||||||
|
match: draftMatch.value.trim(),
|
||||||
|
moment: draftMoment.value,
|
||||||
|
reason: draftReason.value.trim(),
|
||||||
|
});
|
||||||
|
draftTool.value = "";
|
||||||
|
draftMatch.value = "";
|
||||||
|
draftReason.value = "";
|
||||||
|
} catch {
|
||||||
|
// Kept as typed, so the refusal the store showed can be acted on.
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => store.fetch(true));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="moments-settings">
|
||||||
|
<p v-if="store.loading && !data" class="empty-msg">Loading moments…</p>
|
||||||
|
<p v-else-if="store.failed && !data" class="error-msg">
|
||||||
|
The moment catalog could not be loaded.
|
||||||
|
<button type="button" class="btn-text" @click="store.fetch(true)">Try again</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else-if="data">
|
||||||
|
<p class="window-note">
|
||||||
|
Deliveries and opens over the last {{ days }} days. “Opened” counts the rules an agent
|
||||||
|
went on to read — an upper bound, since a rule delivered at two moments and read once
|
||||||
|
counts at both.
|
||||||
|
</p>
|
||||||
|
<p v-if="usageFailed" class="error-msg">
|
||||||
|
The delivery counts could not be read, so they are missing below — not zero.
|
||||||
|
</p>
|
||||||
|
<p v-if="data.mounted_failed" class="error-msg">
|
||||||
|
The mount counts could not be read, so they are missing below — not zero.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="moment-list">
|
||||||
|
<li v-for="m in catalog" :key="m.name" class="moment-row">
|
||||||
|
<div class="moment-head">
|
||||||
|
<code class="moment-name">{{ m.name }}</code>
|
||||||
|
<span class="moment-stats">
|
||||||
|
<span :class="{ 'stat-none': !mounted(m.name) }">
|
||||||
|
{{ mounted(m.name) ? `${mounted(m.name)} ${mounted(m.name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
|
||||||
|
</span>
|
||||||
|
<template v-if="usage(m.name)">
|
||||||
|
<span>delivered {{ usage(m.name)!.delivered }}</span>
|
||||||
|
<span>opened {{ usage(m.name)!.opened }} of {{ usage(m.name)!.rules }}</span>
|
||||||
|
<span v-if="usage(m.name)!.last_delivered_at" class="stat-when">
|
||||||
|
last {{ fmtDate(usage(m.name)!.last_delivered_at!) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="moment-desc">{{ m.means }}</p>
|
||||||
|
<div class="moment-actions">
|
||||||
|
<span class="actions-label">Reached by</span>
|
||||||
|
<span v-if="!actionsFor(m.name).length" class="stat-none">
|
||||||
|
no action here — {{ m.reached_by }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="a in actionsFor(m.name)"
|
||||||
|
:key="`${a.via}:${a.tool}:${a.match}`"
|
||||||
|
:class="['action-chip', { 'is-install': a.via === 'install' }]"
|
||||||
|
:title="a.via === 'install' ? 'Added on this install' : 'Ships with Scribe'"
|
||||||
|
>
|
||||||
|
<code>{{ actionLabel(a) }}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-remove"
|
||||||
|
:disabled="busy"
|
||||||
|
:aria-label="a.via === 'install'
|
||||||
|
? `Remove ${actionLabel(a)} from ${m.name}`
|
||||||
|
: `Switch off the shipped ${actionLabel(a)} for ${m.name}`"
|
||||||
|
@click="removeAction(m.name, a)"
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template v-if="procedureMoments.length">
|
||||||
|
<h3 class="sub-title">Named procedures</h3>
|
||||||
|
<ul class="moment-list">
|
||||||
|
<li v-for="name in procedureMoments" :key="name" class="moment-row">
|
||||||
|
<div class="moment-head">
|
||||||
|
<code class="moment-name">{{ name }}</code>
|
||||||
|
<span class="moment-stats">
|
||||||
|
<span :class="{ 'stat-none': !mounted(name) }">
|
||||||
|
{{ mounted(name) ? `${mounted(name)} ${mounted(name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
|
||||||
|
</span>
|
||||||
|
<template v-if="usage(name)">
|
||||||
|
<span>delivered {{ usage(name)!.delivered }}</span>
|
||||||
|
<span>opened {{ usage(name)!.opened }} of {{ usage(name)!.rules }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="data.removed_defaults.length">
|
||||||
|
<h3 class="sub-title">Shipped mappings switched off here</h3>
|
||||||
|
<ul class="removed-list">
|
||||||
|
<li v-for="r in data.removed_defaults" :key="r.id" class="removed-row">
|
||||||
|
<code>{{ actionLabel(r) }}</code>
|
||||||
|
<span class="removed-arrow">→</span>
|
||||||
|
<code>{{ r.moment }}</code>
|
||||||
|
<span v-if="r.reason" class="removed-reason">{{ r.reason }}</span>
|
||||||
|
<button type="button" class="btn-text" :disabled="busy" @click="restore(r)">Restore</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form class="add-mapping" @submit.prevent="addMapping">
|
||||||
|
<h3 class="sub-title">Add an action</h3>
|
||||||
|
<p class="field-hint">
|
||||||
|
When something you do plainly happens at a moment and nothing fired — your own deploy
|
||||||
|
script, a tool from another MCP server. For a command tool, the match is how the command
|
||||||
|
starts (<code>make ship</code>); for any other tool, its arguments as
|
||||||
|
<code>field=value</code> pairs. Leave it empty to map every call.
|
||||||
|
</p>
|
||||||
|
<div class="add-grid">
|
||||||
|
<label>
|
||||||
|
Tool
|
||||||
|
<input v-model="draftTool" class="fs-input" placeholder="Bash, or an MCP tool's name" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Match
|
||||||
|
<input v-model="draftMatch" class="fs-input" placeholder="make ship · status=done · empty for every call" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Moment
|
||||||
|
<select v-model="draftMoment" class="fs-input">
|
||||||
|
<option value="" disabled>Choose a moment</option>
|
||||||
|
<option v-for="m in catalog" :key="m.name" :value="m.name">{{ m.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Why <span class="optional">(optional)</span>
|
||||||
|
<input v-model="draftReason" class="fs-input" placeholder="what it is for here" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="!canAdd">Add action</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.moments-settings { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||||
|
.window-note { margin: 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
||||||
|
|
||||||
|
.moment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||||
|
.moment-row {
|
||||||
|
padding: var(--fs-space-3);
|
||||||
|
background: var(--fs-surface-page);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
}
|
||||||
|
.moment-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||||
|
.moment-head .moment-name { font-size: var(--fs-size-body-sm); }
|
||||||
|
.moment-stats {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex; flex-wrap: wrap; gap: var(--fs-space-3);
|
||||||
|
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
/* Nothing mounted is the ordinary state of most moments, not a fault. */
|
||||||
|
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
|
||||||
|
.stat-when { color: var(--fs-text-tertiary); }
|
||||||
|
.moment-desc { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
|
||||||
|
|
||||||
|
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-tiny); }
|
||||||
|
.actions-label { color: var(--fs-text-tertiary); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
|
||||||
|
.action-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: var(--fs-space-1);
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
.action-chip code { font-family: var(--fs-font-mono); }
|
||||||
|
/* This install's own, set apart from the shipped ones the way the tuning
|
||||||
|
trail marks the operator's changes: a dashed edge, no accent. */
|
||||||
|
.action-chip.is-install { border-style: dashed; color: var(--fs-text-primary); }
|
||||||
|
|
||||||
|
.sub-title { margin: var(--fs-space-3) 0 var(--fs-space-1); font-size: 0.95rem; color: var(--fs-text-primary); }
|
||||||
|
|
||||||
|
.removed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||||
|
.removed-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
|
||||||
|
.removed-row code { font-family: var(--fs-font-mono); color: var(--fs-text-secondary); text-decoration: line-through; }
|
||||||
|
.removed-arrow { color: var(--fs-text-tertiary); }
|
||||||
|
.removed-reason { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
|
||||||
|
|
||||||
|
.add-mapping { display: flex; flex-direction: column; gap: var(--fs-space-2); align-items: flex-start; }
|
||||||
|
.add-grid {
|
||||||
|
width: 100%;
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: var(--fs-space-2) var(--fs-space-3);
|
||||||
|
}
|
||||||
|
.add-grid label { display: flex; flex-direction: column; gap: var(--fs-space-1); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
|
||||||
|
.optional { color: var(--fs-text-tertiary); font-weight: normal; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style src="@/assets/moments-shared.css" />
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch, onMounted } from "vue";
|
import { computed, ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
|
import { useMomentsStore } from "@/stores/moments";
|
||||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||||
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
||||||
import type { Rule, RuleKind } from "@/api/rulebooks";
|
import type { Rule, RuleKind } from "@/api/rulebooks";
|
||||||
@@ -11,6 +12,7 @@ const emit = defineEmits<{ close: [] }>();
|
|||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const canon = useCanonicalSystemsStore();
|
const canon = useCanonicalSystemsStore();
|
||||||
|
const momentsStore = useMomentsStore();
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
const whenToApply = ref("");
|
const whenToApply = ref("");
|
||||||
@@ -20,6 +22,18 @@ const whenToApply = ref("");
|
|||||||
// session may quietly rewrite.
|
// session may quietly rewrite.
|
||||||
const kind = ref<RuleKind>("rule");
|
const kind = ref<RuleKind>("rule");
|
||||||
const systemIds = ref<number[]>([]);
|
const systemIds = ref<number[]>([]);
|
||||||
|
// The moments this rule is mounted on (milestone 458). Catalog moments are
|
||||||
|
// ticked; a named procedure's moment (`skill.<name>`) is typed, since its
|
||||||
|
// name is the procedure's own and no list here could know it.
|
||||||
|
const ruleMoments = ref<string[]>([]);
|
||||||
|
const procedureDraft = ref("");
|
||||||
|
const procedureError = ref("");
|
||||||
|
const SKILL_PREFIX = "skill.";
|
||||||
|
const SKILL_NAME = /^[a-z0-9][a-z0-9_:-]*$/;
|
||||||
|
const momentCatalog = computed(() => momentsStore.data?.moments ?? []);
|
||||||
|
const procedureMoments = computed(() =>
|
||||||
|
ruleMoments.value.filter((m) => m.startsWith(SKILL_PREFIX)),
|
||||||
|
);
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = ref("");
|
||||||
const verifyWith = ref("");
|
const verifyWith = ref("");
|
||||||
@@ -46,6 +60,26 @@ function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
|||||||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleMoment(name: string) {
|
||||||
|
const at = ruleMoments.value.indexOf(name);
|
||||||
|
if (at >= 0) ruleMoments.value.splice(at, 1);
|
||||||
|
else ruleMoments.value.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same check the server makes (services/moments.is_moment), so a typo is
|
||||||
|
// caught at the field rather than as a failed save.
|
||||||
|
function addProcedureMoment() {
|
||||||
|
const raw = procedureDraft.value.trim().toLowerCase();
|
||||||
|
const name = raw.startsWith(SKILL_PREFIX) ? raw : SKILL_PREFIX + raw;
|
||||||
|
if (!SKILL_NAME.test(name.slice(SKILL_PREFIX.length))) {
|
||||||
|
procedureError.value = "A procedure name is lowercase letters, digits, - _ and :, with no spaces.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
procedureError.value = "";
|
||||||
|
if (!ruleMoments.value.includes(name)) ruleMoments.value.push(name);
|
||||||
|
procedureDraft.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSystem(id: number) {
|
function toggleSystem(id: number) {
|
||||||
const at = systemIds.value.indexOf(id);
|
const at = systemIds.value.indexOf(id);
|
||||||
if (at >= 0) systemIds.value.splice(at, 1);
|
if (at >= 0) systemIds.value.splice(at, 1);
|
||||||
@@ -85,6 +119,7 @@ async function load() {
|
|||||||
whenToApply.value = r.when_to_apply || "";
|
whenToApply.value = r.when_to_apply || "";
|
||||||
kind.value = r.kind;
|
kind.value = r.kind;
|
||||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||||
|
ruleMoments.value = [...(r.moments ?? [])];
|
||||||
why.value = r.why || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
verifyWith.value = r.verify_with || "";
|
verifyWith.value = r.verify_with || "";
|
||||||
@@ -96,12 +131,15 @@ async function load() {
|
|||||||
whenToApply.value = "";
|
whenToApply.value = "";
|
||||||
kind.value = "rule";
|
kind.value = "rule";
|
||||||
systemIds.value = [];
|
systemIds.value = [];
|
||||||
|
ruleMoments.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
verifyWith.value = "";
|
verifyWith.value = "";
|
||||||
expiresWhen.value = "";
|
expiresWhen.value = "";
|
||||||
}
|
}
|
||||||
await canon.fetchCatalog();
|
procedureDraft.value = "";
|
||||||
|
procedureError.value = "";
|
||||||
|
await Promise.all([canon.fetchCatalog(), momentsStore.fetch()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -117,6 +155,9 @@ async function save() {
|
|||||||
// Always sent, so clearing the last area actually clears it — the server
|
// Always sent, so clearing the last area actually clears it — the server
|
||||||
// reads a list as "these ARE the areas now".
|
// reads a list as "these ARE the areas now".
|
||||||
system_ids: systemIds.value,
|
system_ids: systemIds.value,
|
||||||
|
// Always sent, for system_ids' reason: the server reads the list as the
|
||||||
|
// whole set, so unticking the last moment actually unmounts the rule.
|
||||||
|
moments: ruleMoments.value,
|
||||||
why: why.value,
|
why: why.value,
|
||||||
how_to_apply: howToApply.value,
|
how_to_apply: howToApply.value,
|
||||||
// Always sent, including empty. The REST door maps "" to NULL, so
|
// Always sent, including empty. The REST door maps "" to NULL, so
|
||||||
@@ -210,6 +251,54 @@ watch(() => props.ruleId, load);
|
|||||||
its trigger, so an empty trigger leaves the rule findable by nobody.
|
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<fieldset v-if="momentCatalog.length" class="moments">
|
||||||
|
<legend>Moments it arrives at</legend>
|
||||||
|
<p class="field-note intro">
|
||||||
|
Whenever one of these happens, this rule is delivered — whatever the
|
||||||
|
words of the work look like. Use it for a rule about <em>when</em>
|
||||||
|
something is done rather than what it is about: a rule on when work
|
||||||
|
counts as finished belongs where work is finished and reported, and
|
||||||
|
nothing said there needs to resemble it.
|
||||||
|
</p>
|
||||||
|
<label v-for="m in momentCatalog" :key="m.name" class="area-opt">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="ruleMoments.includes(m.name)"
|
||||||
|
@change="toggleMoment(m.name)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<code class="moment-name">{{ m.name }}</code>
|
||||||
|
<span class="moment-means">{{ m.means }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="procedureMoments.length" class="procedure-moments">
|
||||||
|
<span v-for="m in procedureMoments" :key="m" class="rule-chip procedure-chip">
|
||||||
|
<code>{{ m }}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-remove"
|
||||||
|
:aria-label="`Unmount from ${m}`"
|
||||||
|
@click="toggleMoment(m)"
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="procedure-add">
|
||||||
|
<input
|
||||||
|
v-model="procedureDraft"
|
||||||
|
aria-label="A named procedure this rule arrives with"
|
||||||
|
placeholder="skill.release-notes — when a named procedure is loaded"
|
||||||
|
@keydown.enter.prevent="addProcedureMoment"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary btn-sm"
|
||||||
|
:disabled="!procedureDraft.trim()"
|
||||||
|
@click="addProcedureMoment"
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="procedureError" class="trigger-warning">{{ procedureError }}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset v-if="canon.catalog.length" class="areas">
|
<fieldset v-if="canon.catalog.length" class="areas">
|
||||||
<legend>Areas this rule is about</legend>
|
<legend>Areas this rule is about</legend>
|
||||||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||||||
@@ -370,6 +459,17 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
|||||||
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||||
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
.trigger-missing textarea { border-color: var(--fs-warning); }
|
.trigger-missing textarea { border-color: var(--fs-warning); }
|
||||||
|
/* The moment picker (milestone 458): the name is what a session reads in a
|
||||||
|
delivered line, so it is shown as written; the meaning is the gloss. */
|
||||||
|
.moments { margin-bottom: 1rem; }
|
||||||
|
.moments .intro { margin-top: 0; margin-bottom: var(--fs-space-3); }
|
||||||
|
.moments .moment-name { font-size: var(--fs-size-tiny); }
|
||||||
|
.moment-means { display: block; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
||||||
|
.procedure-moments { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); margin: var(--fs-space-2) 0; }
|
||||||
|
.procedure-chip { display: inline-flex; align-items: center; gap: var(--fs-space-1); }
|
||||||
|
.procedure-add { display: flex; gap: var(--fs-space-2); align-items: center; margin-top: var(--fs-space-2); }
|
||||||
|
.procedure-add input { margin-top: 0; }
|
||||||
|
.procedure-add .btn-sm { flex: none; }
|
||||||
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
||||||
between the warning HUE and warning text, and this is text. */
|
between the warning HUE and warning text, and this is text. */
|
||||||
.trigger-warning {
|
.trigger-warning {
|
||||||
@@ -422,3 +522,4 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
|||||||
panes already use, loaded here because the slide-over can open with no
|
panes already use, loaded here because the slide-over can open with no
|
||||||
pane that loads it (a link straight to ?rule=N). -->
|
pane that loads it (a link straight to ?rule=N). -->
|
||||||
<style src="@/assets/rules-shared.css" />
|
<style src="@/assets/rules-shared.css" />
|
||||||
|
<style src="@/assets/moments-shared.css" />
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import * as api from "@/api/moments";
|
||||||
|
import type { MappingChange, MomentsPayload } from "@/api/moments";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moment catalog and this install's mappings (milestone 458 step 6).
|
||||||
|
*
|
||||||
|
* Shared by the rule editor's picker and the Settings section, so the two
|
||||||
|
* cannot offer different moments. The catalog is the product's and changes
|
||||||
|
* only with a release; the mappings and counts change with every write here,
|
||||||
|
* so a write re-reads the whole payload rather than patching it locally.
|
||||||
|
*/
|
||||||
|
export const useMomentsStore = defineStore("moments", () => {
|
||||||
|
const data = ref<MomentsPayload | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
async function fetch(force = false) {
|
||||||
|
if (data.value && !force) return data.value;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
data.value = await api.getMoments();
|
||||||
|
failed.value = false;
|
||||||
|
} catch {
|
||||||
|
// The picker rides on the rule editor: an unreachable catalog hides
|
||||||
|
// the picker, it does not break the form it sits in.
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
return data.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function change(kind: "map" | "unmap", mapping: MappingChange) {
|
||||||
|
try {
|
||||||
|
if (kind === "map") await api.mapAction(mapping);
|
||||||
|
else await api.unmapAction(mapping);
|
||||||
|
} catch (e) {
|
||||||
|
useToastStore().show(
|
||||||
|
apiErrorMessage(e, kind === "map" ? "Could not add the mapping" : "Could not remove the mapping"),
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
await fetch(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data, loading, failed, fetch,
|
||||||
|
map: (m: MappingChange) => change("map", m),
|
||||||
|
unmap: (m: MappingChange) => change("unmap", m),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou
|
|||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||||
|
|
||||||
@@ -2222,6 +2223,17 @@ async function deleteUser(userId: number) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Moments</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
A rule mounted on a moment arrives whenever that moment happens, whatever the work
|
||||||
|
looks like — which is how a rule about <em>when</em> something is done reaches a session
|
||||||
|
that never mentions it. Mount rules from the rule editor; here is what each moment
|
||||||
|
means, which actions reach it on this install, and what it has delivered.
|
||||||
|
</p>
|
||||||
|
<MomentsSettings />
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Account ── -->
|
<!-- ── Account ── -->
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ So every change to a retrieval dial goes through `set_dial`, from the UI as
|
|||||||
much as from the MCP tool, and the only difference is the `actor` recorded.
|
much as from the MCP tool, and the only difference is the `actor` recorded.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from scribe.auth import get_current_user_id, login_required
|
from scribe.auth import get_current_user_id, login_required
|
||||||
from scribe.services import moment_actions as moment_actions_svc
|
from scribe.services import moment_actions as moment_actions_svc
|
||||||
from scribe.services import moments as moments_svc
|
from scribe.services import moments as moments_svc
|
||||||
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services.retrieval_telemetry import moment_usage
|
||||||
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
|
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -115,16 +118,39 @@ async def moments_route():
|
|||||||
actions that reach each one on this install.
|
actions that reach each one on this install.
|
||||||
|
|
||||||
The same payload `list_moments` returns, from the same services, so the
|
The same payload `list_moments` returns, from the same services, so the
|
||||||
session and the Settings view cannot name different moments.
|
session and the Settings view cannot name different moments — plus what
|
||||||
|
the view shows beside each one (step 6): `mounted`, how many rules each
|
||||||
|
carries, and `usage`, the deliveries and opens per moment over `days`
|
||||||
|
(the block `retrieval_telemetry` reports as `moment_usage`).
|
||||||
"""
|
"""
|
||||||
|
uid = get_current_user_id()
|
||||||
|
try:
|
||||||
|
days = max(1, min(int(request.args.get("days", 30)), 365))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "days must be a whole number"}), 400
|
||||||
out = moments_svc.catalog()
|
out = moments_svc.catalog()
|
||||||
out.update(await moment_actions_svc.actions_by_moment(get_current_user_id()))
|
out.update(await moment_actions_svc.actions_by_moment(uid))
|
||||||
|
# The catalog is the page; the counts beside it are not worth losing it
|
||||||
|
# over. A failed count is flagged rather than shown as "nothing mounted".
|
||||||
|
try:
|
||||||
|
out["mounted"] = await rulebooks_svc.mount_counts(uid)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("mount counts unreadable", exc_info=True)
|
||||||
|
out["mounted"], out["mounted_failed"] = {}, True
|
||||||
|
out["usage"] = await moment_usage(uid, datetime.now(timezone.utc) - timedelta(days=days))
|
||||||
|
out["usage"]["days"] = days
|
||||||
return jsonify(out)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
async def _mapping_change(change):
|
async def _mapping_change(change):
|
||||||
"""map/unmap from the browser: the MCP tools' service, recorded as human."""
|
"""map/unmap from the browser: the MCP tools' service, recorded as human.
|
||||||
data = await request.get_json()
|
|
||||||
|
The mapping comes as a JSON body, or — for DELETE, whose body many
|
||||||
|
clients will not send — as query parameters of the same names.
|
||||||
|
"""
|
||||||
|
data = await request.get_json(silent=True)
|
||||||
|
if data is None and request.args:
|
||||||
|
data = request.args.to_dict()
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return jsonify({"error": "Expected a JSON object"}), 400
|
return jsonify({"error": "Expected a JSON object"}), 400
|
||||||
if not data.get("tool") or not data.get("moment"):
|
if not data.get("tool") or not data.get("moment"):
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ from typing import Any
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, exists, func, select
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.base import iso
|
from scribe.models.base import iso
|
||||||
@@ -985,6 +986,94 @@ async def _system_usage(user_id: int | None, since) -> dict:
|
|||||||
return block
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def moment_usage(user_id: int | None, since) -> dict:
|
||||||
|
"""Deliveries and opens per moment (milestone 458 step 6).
|
||||||
|
|
||||||
|
A mounted rule's delivery is a `moment_rule` surfacing whose `detail` is
|
||||||
|
the moment it arrived at — the plugin's catch-all hook, the MCP tools'
|
||||||
|
attached lines and the reply hold all record it so. Per moment:
|
||||||
|
|
||||||
|
- `delivered`: how many times a mounted rule arrived there;
|
||||||
|
- `rules`: how many distinct rules did;
|
||||||
|
- `opened`: how many of those an AGENT then opened (`mcp_*`), at or after
|
||||||
|
their first delivery there in the window;
|
||||||
|
- `last_delivered_at`.
|
||||||
|
|
||||||
|
`opened` is an UPPER BOUND, for by_source's reason: a pull records the
|
||||||
|
door, not the line that prompted it, so a rule delivered at two moments
|
||||||
|
and opened once counts as opened at both. A moment reading near zero is
|
||||||
|
not being flattered by it.
|
||||||
|
|
||||||
|
NO RATIO. A mount is a person's statement that the rule belongs at that
|
||||||
|
moment, not a ranker's guess, so "delivered often, opened rarely" is a
|
||||||
|
question about the rule's wording or the agent, not a bar to tune. The
|
||||||
|
counts sit side by side.
|
||||||
|
|
||||||
|
Its own session and guard (#2663): a failure keeps the shape and adds
|
||||||
|
`moment_usage_failed`, never zeros that read as "nothing happened".
|
||||||
|
"""
|
||||||
|
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE
|
||||||
|
|
||||||
|
block: dict = {"by_moment": {}}
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
per_rule = (
|
||||||
|
select(
|
||||||
|
RuleUsageEvent.detail.label("moment"),
|
||||||
|
RuleUsageEvent.rule_id,
|
||||||
|
func.count().label("n"),
|
||||||
|
func.min(RuleUsageEvent.created_at).label("first_at"),
|
||||||
|
func.max(RuleUsageEvent.created_at).label("last_at"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
RuleUsageEvent.created_at >= since,
|
||||||
|
RuleUsageEvent.user_id == user_id,
|
||||||
|
RuleUsageEvent.event == RULE_SURFACED,
|
||||||
|
RuleUsageEvent.source == MOMENT_RULE_SOURCE,
|
||||||
|
RuleUsageEvent.detail.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(RuleUsageEvent.detail, RuleUsageEvent.rule_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
pull = aliased(RuleUsageEvent)
|
||||||
|
opened = exists().where(
|
||||||
|
pull.rule_id == per_rule.c.rule_id,
|
||||||
|
pull.user_id == user_id,
|
||||||
|
pull.event == RULE_PULLED,
|
||||||
|
# autoescape: `_` is a LIKE wildcard (see the note block).
|
||||||
|
pull.source.startswith("mcp_", autoescape=True),
|
||||||
|
pull.created_at >= per_rule.c.first_at,
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
per_rule.c.moment,
|
||||||
|
func.sum(per_rule.c.n).label("delivered"),
|
||||||
|
func.count().label("rules"),
|
||||||
|
func.count(case((opened, 1))).label("opened"),
|
||||||
|
func.max(per_rule.c.last_at).label("last_at"),
|
||||||
|
).group_by(per_rule.c.moment)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
complete = await _complete_from(session, RuleUsageEvent, user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("moment usage read failed", exc_info=True)
|
||||||
|
block["moment_usage_failed"] = True
|
||||||
|
return block
|
||||||
|
|
||||||
|
block["by_moment"] = {
|
||||||
|
moment: {
|
||||||
|
"delivered": int(delivered or 0),
|
||||||
|
"rules": int(rules or 0),
|
||||||
|
"opened": int(n_opened or 0),
|
||||||
|
"last_delivered_at": iso(last_at),
|
||||||
|
}
|
||||||
|
for moment, delivered, rules, n_opened, last_at in rows
|
||||||
|
}
|
||||||
|
block.update(_coverage(complete.get("*"), since))
|
||||||
|
return block
|
||||||
|
|
||||||
async def retrieval_summary(
|
async def retrieval_summary(
|
||||||
user_id: int | None, *, days: int = 30, near_miss_samples: int = 0,
|
user_id: int | None, *, days: int = 30, near_miss_samples: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -1583,6 +1672,7 @@ async def retrieval_summary(
|
|||||||
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
||||||
out["rule_usage"] = rule_usage
|
out["rule_usage"] = rule_usage
|
||||||
out["system_usage"] = await _system_usage(user_id, since)
|
out["system_usage"] = await _system_usage(user_id, since)
|
||||||
|
out["moment_usage"] = await moment_usage(user_id, since)
|
||||||
|
|
||||||
# ── Judged lines (#4772) ─────────────────────────────────────────────
|
# ── Judged lines (#4772) ─────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1208,6 +1208,16 @@ async def mounted_moments(user_id: int) -> set[str]:
|
|||||||
tool whose moments carry nothing. A superset is the safe direction — the
|
tool whose moments carry nothing. A superset is the safe direction — the
|
||||||
delivery read still scopes by project.
|
delivery read still scopes by project.
|
||||||
"""
|
"""
|
||||||
|
return set(await mount_counts(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def mount_counts(user_id: int) -> dict[str, int]:
|
||||||
|
"""How many of this caller's live rules are mounted on each moment.
|
||||||
|
|
||||||
|
The same read as `mounted_moments`, counted — what the Settings view
|
||||||
|
shows beside each moment, so a moment carrying nothing reads as such.
|
||||||
|
Moments with no mount are absent rather than zero.
|
||||||
|
"""
|
||||||
from scribe.models.rulebook import rule_moments as rule_moments_t
|
from scribe.models.rulebook import rule_moments as rule_moments_t
|
||||||
from scribe.services.rule_scope import joined_to_homes, rule_home
|
from scribe.services.rule_scope import joined_to_homes, rule_home
|
||||||
|
|
||||||
@@ -1215,13 +1225,14 @@ async def mounted_moments(user_id: int) -> set[str]:
|
|||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (await session.execute(
|
rows = (await session.execute(
|
||||||
joined_to_homes(
|
joined_to_homes(
|
||||||
select(rule_moments_t.c.moment).distinct()
|
select(rule_moments_t.c.moment, func.count(func.distinct(Rule.id)))
|
||||||
.select_from(rule_moments_t)
|
.select_from(rule_moments_t)
|
||||||
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
|
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
|
||||||
)
|
)
|
||||||
.where(Rule.deleted_at.is_(None), home)
|
.where(Rule.deleted_at.is_(None), home)
|
||||||
)).scalars().all()
|
.group_by(rule_moments_t.c.moment)
|
||||||
return set(rows)
|
)).all()
|
||||||
|
return {moment: int(n) for moment, n in rows}
|
||||||
|
|
||||||
|
|
||||||
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||||
|
|||||||
@@ -199,3 +199,64 @@ async def test_the_delivery_lookup_answers_by_home(world):
|
|||||||
# The plugin's "can anything arrive" answer spans every home.
|
# The plugin's "can anything arrive" answer spans every home.
|
||||||
assert await rulebooks_svc.mounted_moments(uid) >= {"work.finish", "reply.report", "work.deliver"}
|
assert await rulebooks_svc.mounted_moments(uid) >= {"work.finish", "reply.report", "work.deliver"}
|
||||||
assert not (await rulebooks_svc.mounted_moments(sid)) & {"work.finish", "work.deliver"}
|
assert not (await rulebooks_svc.mounted_moments(sid)) & {"work.finish", "work.deliver"}
|
||||||
|
|
||||||
|
# Counted the same way for the Settings view: a rule mounted twice counts
|
||||||
|
# once per moment, and another user's mounts are not in the count.
|
||||||
|
counts = await rulebooks_svc.mount_counts(uid)
|
||||||
|
assert counts.get("work.deliver", 0) >= 1 and counts.get("reply.report", 0) >= 1
|
||||||
|
assert set(counts) == await rulebooks_svc.mounted_moments(uid)
|
||||||
|
assert not set(await rulebooks_svc.mount_counts(sid)) & {"work.finish", "work.deliver"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_moment_usage_counts_deliveries_and_the_agent_opens_that_followed(world):
|
||||||
|
"""The per-moment readout (step 6), on real SQL: an EXISTS inside a
|
||||||
|
COUNT(CASE …) is a shape no mock can vouch for, and the block's own guard
|
||||||
|
would turn a database refusal into a quiet `moment_usage_failed`."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
|
||||||
|
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE
|
||||||
|
from scribe.services.retrieval_telemetry import moment_usage
|
||||||
|
|
||||||
|
uid, sid, rule = world["uid"], world["sid"], world["rule"]
|
||||||
|
other = await rulebooks_svc.create_rule(
|
||||||
|
rule.topic_id, uid, "Close the task with the push",
|
||||||
|
"A task closes in the turn its CI goes green.",
|
||||||
|
when_to_apply="the CI run on a pushed commit has just gone green",
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def ev(user, rule_id, event, source, ago, detail=None):
|
||||||
|
return RuleUsageEvent(user_id=user, rule_id=rule_id, event=event, source=source,
|
||||||
|
detail=detail, created_at=now - ago)
|
||||||
|
|
||||||
|
async with async_session() as s:
|
||||||
|
await s.execute(delete(RuleUsageEvent).where(RuleUsageEvent.user_id.in_([uid, sid])))
|
||||||
|
s.add_all([
|
||||||
|
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=3), "work.finish"),
|
||||||
|
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=2), "work.finish"),
|
||||||
|
ev(uid, other.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=1), "work.finish"),
|
||||||
|
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=1), "reply.report"),
|
||||||
|
# An agent opened `rule` after its first work.finish delivery, and
|
||||||
|
# BEFORE its reply.report one — so it counts at the first only.
|
||||||
|
ev(uid, rule.id, PULLED, "mcp_get_rule", timedelta(minutes=150)),
|
||||||
|
# A person opening `other` in the browser is not an agent open.
|
||||||
|
ev(uid, other.id, PULLED, "rest_rule", timedelta(minutes=5)),
|
||||||
|
# A ranked surfacing is not a delivery at a moment.
|
||||||
|
ev(uid, other.id, SURFACED, "pre_tool_rule", timedelta(minutes=5)),
|
||||||
|
# Nor is a delivery older than the window, or someone else's.
|
||||||
|
ev(uid, other.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(days=3), "work.deliver"),
|
||||||
|
ev(sid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(minutes=5), "work.finish"),
|
||||||
|
])
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
block = await moment_usage(uid, now - timedelta(days=1))
|
||||||
|
assert "moment_usage_failed" not in block
|
||||||
|
assert set(block["by_moment"]) == {"work.finish", "reply.report"}
|
||||||
|
finish = block["by_moment"]["work.finish"]
|
||||||
|
assert (finish["delivered"], finish["rules"], finish["opened"]) == (3, 2, 1)
|
||||||
|
report = block["by_moment"]["reply.report"]
|
||||||
|
assert (report["delivered"], report["rules"], report["opened"]) == (1, 1, 0)
|
||||||
|
assert report["last_delivered_at"]
|
||||||
|
|||||||
@@ -133,3 +133,80 @@ async def test_setting_a_dial_to_the_value_it_already_has_records_nothing():
|
|||||||
# And the setting is left alone too: rewriting the same value would bump
|
# And the setting is left alone too: rewriting the same value would bump
|
||||||
# whatever timestamp the row carries for no reason.
|
# whatever timestamp the row carries for no reason.
|
||||||
setter.assert_not_called()
|
setter.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── the moments view (milestone 458 step 6) ─────────────────────────────
|
||||||
|
|
||||||
|
async def _moments_call(handler_name, path, method="GET", **kw):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from quart import Quart, g
|
||||||
|
|
||||||
|
from scribe.routes import retrieval as routes
|
||||||
|
|
||||||
|
app = Quart(__name__)
|
||||||
|
async with app.test_request_context(path, method=method, **kw):
|
||||||
|
g.user = SimpleNamespace(id=7)
|
||||||
|
resp = await getattr(routes, handler_name).__wrapped__()
|
||||||
|
if isinstance(resp, tuple):
|
||||||
|
return await resp[0].get_json(), resp[1]
|
||||||
|
return await resp.get_json(), 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_view_gets_the_catalog_with_counts_beside_it():
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from scribe.routes import retrieval as routes
|
||||||
|
|
||||||
|
usage = AsyncMock(return_value={"by_moment": {"work.finish": {"delivered": 2}}})
|
||||||
|
with patch.object(routes.moment_actions_svc, "actions_by_moment",
|
||||||
|
AsyncMock(return_value={"actions": {}, "removed_defaults": []})), \
|
||||||
|
patch.object(routes.rulebooks_svc, "mount_counts", AsyncMock(return_value={"work.finish": 1})), \
|
||||||
|
patch.object(routes, "moment_usage", usage):
|
||||||
|
body, status = await _moments_call("moments_route", "/api/retrieval/moments",
|
||||||
|
query_string={"days": "7"})
|
||||||
|
assert status == 200
|
||||||
|
assert body["mounted"] == {"work.finish": 1}
|
||||||
|
assert body["usage"]["days"] == 7
|
||||||
|
assert body["usage"]["by_moment"]["work.finish"]["delivered"] == 2
|
||||||
|
assert {m["name"] for m in body["moments"]} >= {"work.finish", "reply.report"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_failed_mount_count_is_flagged_not_shown_as_nothing():
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from scribe.routes import retrieval as routes
|
||||||
|
|
||||||
|
with patch.object(routes.moment_actions_svc, "actions_by_moment",
|
||||||
|
AsyncMock(return_value={"actions": {}, "removed_defaults": []})), \
|
||||||
|
patch.object(routes.rulebooks_svc, "mount_counts", AsyncMock(side_effect=RuntimeError("down"))), \
|
||||||
|
patch.object(routes, "moment_usage", AsyncMock(return_value={"by_moment": {}})):
|
||||||
|
body, status = await _moments_call("moments_route", "/api/retrieval/moments")
|
||||||
|
assert status == 200
|
||||||
|
assert body["mounted"] == {} and body["mounted_failed"] is True
|
||||||
|
assert body["usage"]["days"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_mapping_removal_can_come_as_query_parameters():
|
||||||
|
"""The browser's DELETE carries no body (api/client.apiDelete), so the
|
||||||
|
mapping it removes arrives in the query string."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from scribe.routes import retrieval as routes
|
||||||
|
|
||||||
|
unmap = AsyncMock(return_value={"change": "removed this install's mapping"})
|
||||||
|
with patch.object(routes.moment_actions_svc, "unmap_action", unmap):
|
||||||
|
body, status = await _moments_call(
|
||||||
|
"unmap_action_route", "/api/retrieval/moments/mappings", method="DELETE",
|
||||||
|
query_string={"tool": "Bash", "match": "make ship", "moment": "work.deliver"},
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert unmap.await_args.args == (7, "Bash", "make ship", "work.deliver")
|
||||||
|
assert unmap.await_args.kwargs["actor"] == "human"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_mapping_with_neither_body_nor_parameters_is_refused():
|
||||||
|
body, status = await _moments_call(
|
||||||
|
"unmap_action_route", "/api/retrieval/moments/mappings", method="DELETE",
|
||||||
|
)
|
||||||
|
assert status == 400
|
||||||
|
|||||||
Reference in New Issue
Block a user