Merge dev: family canon, milestone 463 steps 1-6 #206
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Family canon (milestone 463): ideas every project on a platform shares, and
|
||||||
|
* the log of every decision about them.
|
||||||
|
*
|
||||||
|
* The agent promotes by written criteria — no approval step. This door is for
|
||||||
|
* reading what was decided and why, and for retiring an idea or undoing a
|
||||||
|
* decision when a person disagrees.
|
||||||
|
*/
|
||||||
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
|
|
||||||
|
export type IdeaStatus = "candidate" | "canon" | "retired";
|
||||||
|
export type DecisionAction = "propose" | "promote" | "revise" | "retire" | "assess" | "undo";
|
||||||
|
|
||||||
|
export interface Criterion {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
test: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FamilyIdea {
|
||||||
|
note_id: number;
|
||||||
|
status: IdeaStatus;
|
||||||
|
applies_when: string;
|
||||||
|
canon_version: number;
|
||||||
|
topic_id: number | null;
|
||||||
|
title: string;
|
||||||
|
note_type: string;
|
||||||
|
is_task: boolean;
|
||||||
|
project_id: number | null;
|
||||||
|
platforms: string[];
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A state snapshot a decision records — slugs, never ids. */
|
||||||
|
export interface IdeaSnapshot {
|
||||||
|
status: IdeaStatus;
|
||||||
|
applies_when: string;
|
||||||
|
canon_version: number;
|
||||||
|
platforms: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FamilyDecision {
|
||||||
|
id: number;
|
||||||
|
idea_id: number;
|
||||||
|
idea_title?: string;
|
||||||
|
project_id: number | null;
|
||||||
|
action: DecisionAction;
|
||||||
|
reason: string;
|
||||||
|
before: IdeaSnapshot | null;
|
||||||
|
after: IdeaSnapshot | null;
|
||||||
|
evidence: Record<string, unknown>;
|
||||||
|
precedent_ids: number[];
|
||||||
|
decided_via: "agent" | "operator" | "system";
|
||||||
|
user_id: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
undoable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> {
|
||||||
|
const q = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||||
|
return apiGet(`/api/family/ideas${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFamilyDecisions(limit = 50, offset = 0): Promise<FamilyDecision[]> {
|
||||||
|
const data = await apiGet<{ decisions: FamilyDecision[] }>(
|
||||||
|
`/api/family/decisions?limit=${limit}&offset=${offset}`,
|
||||||
|
);
|
||||||
|
return data.decisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function undoFamilyDecision(id: number, reason: string) {
|
||||||
|
return apiPost<{ decision: FamilyDecision }>(`/api/family/decisions/${id}/undo`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retireFamilyIdea(noteId: number, reason: string) {
|
||||||
|
return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
<router-link to="/family" class="nav-link">Family</router-link>
|
||||||
<!-- A design system is a RECORD you author, not a setting. It sat in
|
<!-- A design system is a RECORD you author, not a setting. It sat in
|
||||||
the utility cluster with Trash and Settings while /design was a
|
the utility cluster with Trash and Settings while /design was a
|
||||||
read-only gallery, and stayed there after it became a record type
|
read-only gallery, and stayed there after it became a record type
|
||||||
@@ -102,6 +103,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
<router-link to="/family" class="nav-link">Family</router-link>
|
||||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ watch(() => props.projectId, load);
|
|||||||
What this project is built on or ships as. Ideas the family has proven
|
What this project is built on or ships as. Ideas the family has proven
|
||||||
for a platform reach every project that is one, and each project
|
for a platform reach every project that is one, and each project
|
||||||
answers them — adopts, varies with a reason, or owes the work.
|
answers them — adopts, varies with a reason, or owes the work.
|
||||||
|
<router-link to="/family">The family's ideas and decisions</router-link>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ const router = createRouter({
|
|||||||
name: "rules",
|
name: "rules",
|
||||||
component: () => import("@/views/RulesView.vue"),
|
component: () => import("@/views/RulesView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Family canon (milestone 463): ideas shared across every project on a
|
||||||
|
// platform, and the log of every decision about them.
|
||||||
|
path: "/family",
|
||||||
|
name: "family",
|
||||||
|
component: () => import("@/views/FamilyView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// The design systems this install RECORDS — for the projects it tracks,
|
// The design systems this install RECORDS — for the projects it tracks,
|
||||||
// not for the install itself. There was a sibling `/design` that read the
|
// not for the install itself. There was a sibling `/design` that read the
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Where a record opens. A task, a snippet, a lesson and a note live at
|
||||||
|
* different routes, and a link that guesses wrong is worse than one that is
|
||||||
|
* plain. One copy, for every list that links a record of any kind. */
|
||||||
|
export function recordHref(rec: { id: number; is_task?: boolean; note_type?: string }): string {
|
||||||
|
if (rec.is_task) return `/tasks/${rec.id}`;
|
||||||
|
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
|
||||||
|
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
|
||||||
|
return `/notes/${rec.id}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Family canon (milestone 463): the ideas every project on a platform shares,
|
||||||
|
* and the log of every decision about them.
|
||||||
|
*
|
||||||
|
* Nobody approves a promotion — the agent decides against the three criteria
|
||||||
|
* shown at the top, and records why. This page is where a person reads those
|
||||||
|
* decisions, and the two places they can disagree: retire an idea, or undo
|
||||||
|
* the latest decision on one. Both ask for a reason, because the reason is
|
||||||
|
* what the next decision about something similar is checked against.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import {
|
||||||
|
listFamilyDecisions, listFamilyIdeas, retireFamilyIdea, undoFamilyDecision,
|
||||||
|
type Criterion, type FamilyDecision, type FamilyIdea, type IdeaStatus,
|
||||||
|
} from "@/api/family";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
|
import { recordHref } from "@/utils/recordHref";
|
||||||
|
|
||||||
|
const PAGE = 50;
|
||||||
|
const toast = useToastStore();
|
||||||
|
|
||||||
|
const criteria = ref<Criterion[]>([]);
|
||||||
|
const ideas = ref<FamilyIdea[]>([]);
|
||||||
|
const decisions = ref<FamilyDecision[]>([]);
|
||||||
|
const statusFilter = ref<"" | IdeaStatus>("");
|
||||||
|
const loading = ref(true);
|
||||||
|
const failed = ref(false);
|
||||||
|
const moreDecisions = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
|
||||||
|
// One pending reversal at a time: which row, which act, and its reason.
|
||||||
|
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
|
||||||
|
const pendingReason = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
propose: "Proposed",
|
||||||
|
promote: "Promoted",
|
||||||
|
revise: "Revised",
|
||||||
|
retire: "Retired",
|
||||||
|
assess: "Assessed",
|
||||||
|
undo: "Undid",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
failed.value = false;
|
||||||
|
try {
|
||||||
|
const [ideaData, log] = await Promise.all([
|
||||||
|
listFamilyIdeas(statusFilter.value),
|
||||||
|
listFamilyDecisions(PAGE, 0),
|
||||||
|
]);
|
||||||
|
ideas.value = ideaData.ideas;
|
||||||
|
criteria.value = ideaData.criteria;
|
||||||
|
decisions.value = log;
|
||||||
|
moreDecisions.value = log.length === PAGE;
|
||||||
|
} catch {
|
||||||
|
// Said out loud: a log that failed to load must not read as "nothing
|
||||||
|
// has been decided".
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
loadingMore.value = true;
|
||||||
|
try {
|
||||||
|
const next = await listFamilyDecisions(PAGE, decisions.value.length);
|
||||||
|
decisions.value.push(...next);
|
||||||
|
moreDecisions.value = next.length === PAGE;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error");
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterIdeas(value: "" | IdeaStatus) {
|
||||||
|
statusFilter.value = value;
|
||||||
|
try {
|
||||||
|
ideas.value = (await listFamilyIdeas(value)).ideas;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.show(apiErrorMessage(e, "Could not load ideas"), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPending(kind: "undo" | "retire", id: number) {
|
||||||
|
pending.value = { kind, id };
|
||||||
|
pendingReason.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPending() {
|
||||||
|
const p = pending.value;
|
||||||
|
const reason = pendingReason.value.trim();
|
||||||
|
if (!p || !reason || saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (p.kind === "undo") await undoFamilyDecision(p.id, reason);
|
||||||
|
else await retireFamilyIdea(p.id, reason);
|
||||||
|
toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired");
|
||||||
|
pending.value = null;
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.show(apiErrorMessage(e, p.kind === "undo" ? "Could not undo" : "Could not retire"), "error");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ideaById = computed(() => {
|
||||||
|
const out: Record<number, FamilyIdea> = {};
|
||||||
|
for (const i of ideas.value) out[i.note_id] = i;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function ideaLink(d: FamilyDecision): string {
|
||||||
|
const idea = ideaById.value[d.idea_id];
|
||||||
|
return idea ? recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })
|
||||||
|
: `/notes/${d.idea_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of a decision's evidence worth reading, as label → lines. */
|
||||||
|
function evidenceLines(d: FamilyDecision): { label: string; lines: string[] }[] {
|
||||||
|
const ev = d.evidence || {};
|
||||||
|
const out: { label: string; lines: string[] }[] = [];
|
||||||
|
const criteriaReasons = ev.criteria as Record<string, string> | undefined;
|
||||||
|
if (criteriaReasons) {
|
||||||
|
const lines = criteria.value
|
||||||
|
.map((c) => `${c.title}: ${criteriaReasons[c.key] || "— not supported"}`);
|
||||||
|
out.push({ label: "Criteria", lines });
|
||||||
|
}
|
||||||
|
if (Array.isArray(ev.vetoed_by) && ev.vetoed_by.length) {
|
||||||
|
const titles = (ev.vetoed_by as string[])
|
||||||
|
.map((k) => criteria.value.find((c) => c.key === k)?.title ?? k);
|
||||||
|
out.push({ label: "Held back by", lines: titles });
|
||||||
|
}
|
||||||
|
if (Array.isArray(ev.evidence) && ev.evidence.length) {
|
||||||
|
out.push({ label: "Evidence", lines: ev.evidence as string[] });
|
||||||
|
}
|
||||||
|
if (typeof ev.trigger === "string") {
|
||||||
|
out.push({ label: "Trigger", lines: [ev.trigger] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLine(d: FamilyDecision): string {
|
||||||
|
const a = d.after;
|
||||||
|
if (!a) return "";
|
||||||
|
const where = a.platforms.length ? ` · ${a.platforms.join(", ")}` : "";
|
||||||
|
return `${a.status} v${a.canon_version}${where}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Family</h1>
|
||||||
|
</div>
|
||||||
|
<p class="fam-lede">
|
||||||
|
Ideas every project on a platform shares — the stance, the shape and the holes
|
||||||
|
already found, not the code. The agent promotes an idea when all three criteria
|
||||||
|
hold, with no approval step; every decision and its reasons are below, and any
|
||||||
|
of them can be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="loading" class="fam-muted">Loading…</p>
|
||||||
|
|
||||||
|
<div v-else-if="failed" class="fam-note">
|
||||||
|
<strong>The family record couldn't load.</strong>
|
||||||
|
<p>Nothing is known either way — this is a failure, not an empty record.</p>
|
||||||
|
<button class="btn-secondary btn-sm" @click="load">Try again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="fam-section" aria-labelledby="fam-criteria">
|
||||||
|
<h2 id="fam-criteria">What gets promoted</h2>
|
||||||
|
<ol class="fam-criteria">
|
||||||
|
<li v-for="c in criteria" :key="c.key">
|
||||||
|
<strong>{{ c.title }}.</strong> {{ c.test }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fam-section" aria-labelledby="fam-ideas">
|
||||||
|
<div class="fam-section-head">
|
||||||
|
<h2 id="fam-ideas">Ideas</h2>
|
||||||
|
<div class="fam-filter" role="group" aria-label="Filter ideas by state">
|
||||||
|
<button
|
||||||
|
v-for="opt in (['', 'candidate', 'canon', 'retired'] as const)"
|
||||||
|
:key="opt"
|
||||||
|
:class="['btn-ghost', 'btn-sm', { active: statusFilter === opt }]"
|
||||||
|
:aria-pressed="statusFilter === opt"
|
||||||
|
@click="filterIdeas(opt)"
|
||||||
|
>{{ opt || "All" }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!ideas.length" class="fam-muted">
|
||||||
|
<template v-if="statusFilter">No {{ statusFilter }} ideas.</template>
|
||||||
|
<template v-else>
|
||||||
|
No family ideas yet. One appears when a record cites another project's
|
||||||
|
work as its source, when two projects on a platform build the same thing,
|
||||||
|
or when the agent proposes one.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<ul v-else class="fam-list">
|
||||||
|
<li v-for="idea in ideas" :key="idea.note_id" class="fam-row">
|
||||||
|
<div class="fam-row-main">
|
||||||
|
<div class="fam-row-title">
|
||||||
|
<router-link :to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })">
|
||||||
|
{{ idea.title }}
|
||||||
|
</router-link>
|
||||||
|
<span :class="['fam-status', `fam-${idea.status}`]">{{ idea.status }}</span>
|
||||||
|
<span v-if="idea.status !== 'candidate'" class="fam-muted">v{{ idea.canon_version }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="idea.applies_when" class="fam-applies">Applies when: {{ idea.applies_when }}</p>
|
||||||
|
<div v-if="idea.platforms.length" class="fam-platforms">
|
||||||
|
<code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="idea.status !== 'retired'"
|
||||||
|
class="btn-ghost btn-sm"
|
||||||
|
@click="startPending('retire', idea.note_id)"
|
||||||
|
>Retire</button>
|
||||||
|
<form
|
||||||
|
v-if="pending?.kind === 'retire' && pending.id === idea.note_id"
|
||||||
|
class="fam-reason"
|
||||||
|
@submit.prevent="confirmPending"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="pendingReason"
|
||||||
|
class="fs-input"
|
||||||
|
placeholder="Why retire it? The next decision is checked against this."
|
||||||
|
aria-label="Reason for retiring"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn-danger btn-sm" :disabled="!pendingReason.trim() || saving">Retire</button>
|
||||||
|
<button type="button" class="btn-ghost btn-sm" @click="pending = null">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fam-section" aria-labelledby="fam-log">
|
||||||
|
<h2 id="fam-log">Decision log</h2>
|
||||||
|
<p v-if="!decisions.length" class="fam-muted">Nothing has been decided yet.</p>
|
||||||
|
<ul v-else class="fam-list">
|
||||||
|
<li v-for="d in decisions" :key="d.id" class="fam-row fam-decision">
|
||||||
|
<div class="fam-row-main">
|
||||||
|
<div class="fam-row-title">
|
||||||
|
<span class="fam-action">{{ ACTION_LABELS[d.action] ?? d.action }}</span>
|
||||||
|
<router-link :to="ideaLink(d)">{{ d.idea_title ?? `#${d.idea_id}` }}</router-link>
|
||||||
|
<span class="fam-muted">
|
||||||
|
#{{ d.id }} · {{ d.decided_via }} · {{ d.created_at ? fmtStamp(d.created_at) : "" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="fam-reason-text">{{ d.reason }}</p>
|
||||||
|
<p v-if="stateLine(d)" class="fam-muted">Now: {{ stateLine(d) }}</p>
|
||||||
|
<dl v-for="part in evidenceLines(d)" :key="part.label" class="fam-evidence">
|
||||||
|
<dt>{{ part.label }}</dt>
|
||||||
|
<dd v-for="line in part.lines" :key="line">{{ line }}</dd>
|
||||||
|
</dl>
|
||||||
|
<p v-if="d.precedent_ids.length" class="fam-muted">
|
||||||
|
Consistent with decision{{ d.precedent_ids.length === 1 ? "" : "s" }}
|
||||||
|
{{ d.precedent_ids.map((p) => `#${p}`).join(", ") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="d.undoable"
|
||||||
|
class="btn-ghost btn-sm"
|
||||||
|
@click="startPending('undo', d.id)"
|
||||||
|
>Undo</button>
|
||||||
|
<form
|
||||||
|
v-if="pending?.kind === 'undo' && pending.id === d.id"
|
||||||
|
class="fam-reason"
|
||||||
|
@submit.prevent="confirmPending"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="pendingReason"
|
||||||
|
class="fs-input"
|
||||||
|
placeholder="Why undo it? The next decision is checked against this."
|
||||||
|
aria-label="Reason for undoing"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn-danger btn-sm" :disabled="!pendingReason.trim() || saving">Undo</button>
|
||||||
|
<button type="button" class="btn-ghost btn-sm" @click="pending = null">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore">
|
||||||
|
{{ loadingMore ? "Loading…" : "Older decisions" }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fam-lede { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 1.5rem; max-width: 52rem; }
|
||||||
|
.fam-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0; }
|
||||||
|
.fam-note {
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
.fam-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
|
||||||
|
.fam-section { margin-bottom: 2rem; }
|
||||||
|
.fam-section h2 { font-size: 1.05rem; margin: 0 0 0.6rem; }
|
||||||
|
.fam-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.fam-filter { display: flex; gap: 0.25rem; }
|
||||||
|
.fam-filter .active { color: var(--fs-text-primary); border-color: var(--fs-border-color); }
|
||||||
|
.fam-criteria { margin: 0; padding-left: 1.25rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
|
||||||
|
.fam-criteria li { margin-bottom: 0.35rem; }
|
||||||
|
.fam-list { list-style: none; margin: 0 0 0.75rem; padding: 0; }
|
||||||
|
.fam-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.fam-row-main { flex: 1; min-width: 0; }
|
||||||
|
.fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
|
||||||
|
.fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||||
|
.fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
|
||||||
|
.fam-chip {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
background: var(--fs-surface-code-inline);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
}
|
||||||
|
.fam-status {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
border-radius: var(--fs-radius-pill);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.fam-candidate { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
|
||||||
|
.fam-canon { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
|
||||||
|
.fam-retired { color: var(--fs-status-cancelled-fg); }
|
||||||
|
.fam-action { font-weight: 500; color: var(--fs-text-primary); }
|
||||||
|
.fam-reason-text { margin: 0.3rem 0; font-size: 0.9rem; color: var(--fs-text-primary); }
|
||||||
|
.fam-evidence { margin: 0.35rem 0 0; font-size: 0.82rem; }
|
||||||
|
.fam-evidence dt { color: var(--fs-text-tertiary); }
|
||||||
|
.fam-evidence dd { margin: 0 0 0 1rem; color: var(--fs-text-secondary); }
|
||||||
|
.fam-reason { flex-basis: 100%; display: flex; gap: 0.4rem; align-items: center; }
|
||||||
|
.fam-reason .fs-input { flex: 1; min-width: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fam-reason { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,6 +41,7 @@ import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
|
|||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { canWriteRecord } from "@/utils/permission";
|
import { canWriteRecord } from "@/utils/permission";
|
||||||
|
import { recordHref } from "@/utils/recordHref";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -59,14 +60,6 @@ const insightHtml = computed(() =>
|
|||||||
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
|
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Where each source opens. A task and a note live at different routes, and a
|
|
||||||
* link that guesses wrong is worse than one that is plain. */
|
|
||||||
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
|
|
||||||
if (rec.is_task) return `/tasks/${rec.id}`;
|
|
||||||
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
|
|
||||||
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
|
|
||||||
return `/notes/${rec.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
|
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
|
||||||
|
|
||||||
@@ -193,7 +186,7 @@ onMounted(load);
|
|||||||
<h2 class="ld-panel-label">Learned from</h2>
|
<h2 class="ld-panel-label">Learned from</h2>
|
||||||
<ul class="ld-sources">
|
<ul class="ld-sources">
|
||||||
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
|
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
|
||||||
<router-link :to="sourceHref(rec)" class="ld-link">
|
<router-link :to="recordHref(rec)" class="ld-link">
|
||||||
{{ rec.title }}
|
{{ rec.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
|
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from scribe.routes.dashboard import dashboard_bp
|
|||||||
from scribe.routes.systems import systems_bp
|
from scribe.routes.systems import systems_bp
|
||||||
from scribe.routes.canonical_systems import canonical_systems_bp
|
from scribe.routes.canonical_systems import canonical_systems_bp
|
||||||
from scribe.routes.platforms import platforms_bp
|
from scribe.routes.platforms import platforms_bp
|
||||||
|
from scribe.routes.family import family_bp
|
||||||
from scribe.routes.lessons import lessons_bp
|
from scribe.routes.lessons import lessons_bp
|
||||||
from scribe.routes.snippets import snippets_bp
|
from scribe.routes.snippets import snippets_bp
|
||||||
from scribe.routes.webhooks import webhooks_bp
|
from scribe.routes.webhooks import webhooks_bp
|
||||||
@@ -103,6 +104,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(systems_bp)
|
app.register_blueprint(systems_bp)
|
||||||
app.register_blueprint(canonical_systems_bp)
|
app.register_blueprint(canonical_systems_bp)
|
||||||
app.register_blueprint(platforms_bp)
|
app.register_blueprint(platforms_bp)
|
||||||
|
app.register_blueprint(family_bp)
|
||||||
app.register_blueprint(snippets_bp)
|
app.register_blueprint(snippets_bp)
|
||||||
app.register_blueprint(webhooks_bp)
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
# The platform catalog and a project's answers (milestone 463). A pure
|
# The platform catalog and a project's answers (milestone 463). A pure
|
||||||
# read; set_project_platforms is the write.
|
# read; set_project_platforms is the write.
|
||||||
"list_platforms",
|
"list_platforms",
|
||||||
|
# Family canon (milestone 463): ideas, one idea with its precedents, and
|
||||||
|
# the decision log. Reads of records the caller can read.
|
||||||
|
"list_family_ideas", "get_family_idea", "list_family_decisions",
|
||||||
# The pass over the corpus and its queue (milestone 458 step 7): which
|
# The pass over the corpus and its queue (milestone 458 step 7): which
|
||||||
# rules are unjudged and which proposals wait. Reads of the caller's own
|
# rules are unjudged and which proposals wait. Reads of the caller's own
|
||||||
# rules, as list_rules is.
|
# rules, as list_rules is.
|
||||||
@@ -187,6 +190,9 @@ _WRITE_TOOLS = frozenset({
|
|||||||
"create_project", "update_project", "delete_project", "decide_project_inception",
|
"create_project", "update_project", "delete_project", "decide_project_inception",
|
||||||
"create_system", "update_system", "delete_system", "map_system_to_canonical",
|
"create_system", "update_system", "delete_system", "map_system_to_canonical",
|
||||||
"set_project_platforms",
|
"set_project_platforms",
|
||||||
|
# family canon — the promotion engine
|
||||||
|
"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||||
|
"undo_family_decision",
|
||||||
"bind_repo", "unbind_repo",
|
"bind_repo", "unbind_repo",
|
||||||
# snippets, processes, the shape ledger
|
# snippets, processes, the shape ledger
|
||||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ to an MCPServer instance. `register_all(mcp)` is the single entry point called
|
|||||||
from `mcp.server.build_mcp_server`.
|
from `mcp.server.build_mcp_server`.
|
||||||
"""
|
"""
|
||||||
from scribe.mcp.tools import (
|
from scribe.mcp.tools import (
|
||||||
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
|
design_systems, family, lessons, milestones, notes, processes, projects, recent, repos,
|
||||||
moments, platforms, retrieval_review, retrieval_tuning,
|
moments, platforms, retrieval_review, retrieval_tuning,
|
||||||
wide_net,
|
wide_net,
|
||||||
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
||||||
@@ -25,6 +25,7 @@ def register_all(mcp) -> None:
|
|||||||
milestones.register(mcp)
|
milestones.register(mcp)
|
||||||
systems.register(mcp)
|
systems.register(mcp)
|
||||||
platforms.register(mcp)
|
platforms.register(mcp)
|
||||||
|
family.register(mcp)
|
||||||
design_systems.register(mcp)
|
design_systems.register(mcp)
|
||||||
tags.register(mcp)
|
tags.register(mcp)
|
||||||
recent.register(mcp)
|
recent.register(mcp)
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
|
||||||
|
|
||||||
|
Thin wrappers over services/family.py. The agent is the decider here: no
|
||||||
|
person approves a promotion, so the criteria are stated in these docstrings
|
||||||
|
and enforced by the service, and every decision is logged with its reason.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services import family as family_svc
|
||||||
|
|
||||||
|
|
||||||
|
async def list_family_ideas(
|
||||||
|
status: str = "", platform: str = "", limit: int = 50, offset: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Family ideas — records that every project on a platform shares — with
|
||||||
|
their state: `candidate` (waiting to be evaluated, or held back by a
|
||||||
|
criterion), `canon` (promoted; every project on its platforms answers it)
|
||||||
|
or `retired`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: candidate | canon | retired. Empty = all.
|
||||||
|
platform: a platform slug (list_platforms). Empty = all.
|
||||||
|
limit / offset: page through the list.
|
||||||
|
"""
|
||||||
|
ideas = await family_svc.list_ideas(
|
||||||
|
current_user_id(), status=status or None, platform=platform or None,
|
||||||
|
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||||
|
)
|
||||||
|
return {"ideas": ideas, "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_family_idea(note_id: int) -> dict:
|
||||||
|
"""One family idea with what an evaluation needs: its state, platforms,
|
||||||
|
full decision history, ledger counts, THE THREE CRITERIA, and the
|
||||||
|
`precedents` — the decisions on the ideas nearest this one by meaning.
|
||||||
|
Read the precedents before deciding, and decide consistently with them
|
||||||
|
unless this idea differs in a way you can name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: the idea's record id.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
idea = await family_svc.get_idea(uid, note_id)
|
||||||
|
if idea is None:
|
||||||
|
raise ValueError(f"#{note_id} is not a family idea you can read")
|
||||||
|
idea["precedents"] = await family_svc.precedents(uid, note_id)
|
||||||
|
return idea
|
||||||
|
|
||||||
|
|
||||||
|
async def propose_family_idea(note_id: int, reason: str, applies_when: str = "") -> dict:
|
||||||
|
"""Record a note, snippet or lesson as a family-idea CANDIDATE: something
|
||||||
|
you believe every project on some platform will face. It reaches no
|
||||||
|
project's ledger until it is promoted.
|
||||||
|
|
||||||
|
Proposing is cheap and does not promote. When the record already meets
|
||||||
|
the three criteria, call promote_family_idea directly instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: the record that carries the idea.
|
||||||
|
reason: why this looks like a family idea.
|
||||||
|
applies_when: optional draft of when it applies, in platform terms.
|
||||||
|
"""
|
||||||
|
idea, created = await family_svc.propose(
|
||||||
|
current_user_id(), note_id, reason=reason, applies_when=applies_when or None,
|
||||||
|
)
|
||||||
|
return {"idea": idea, "created": created}
|
||||||
|
|
||||||
|
|
||||||
|
async def promote_family_idea(
|
||||||
|
note_id: int,
|
||||||
|
applies_when: str,
|
||||||
|
platforms: list[str],
|
||||||
|
platform_terms: str,
|
||||||
|
platform_problem: str,
|
||||||
|
proven: str,
|
||||||
|
evidence: list[str],
|
||||||
|
reason: str,
|
||||||
|
precedent_ids: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Evaluate a record against the three criteria and, if all hold, promote
|
||||||
|
it to family canon. YOU decide; nobody approves. The record of why is what
|
||||||
|
makes the decision reviewable and the next one consistent.
|
||||||
|
|
||||||
|
An idea is promoted only when ALL THREE hold, and each is answered with
|
||||||
|
your reasoning:
|
||||||
|
|
||||||
|
1. platform_terms — its 'when it applies' is stated in terms of a
|
||||||
|
platform (what a project is built on or ships as), not one app's
|
||||||
|
domain. Any project on the platform could read it and know whether it
|
||||||
|
applies.
|
||||||
|
2. platform_problem — it answers a problem the platform itself causes, or
|
||||||
|
a stance the operator holds across projects. One app's preference does
|
||||||
|
not qualify.
|
||||||
|
3. proven — it has worked for real at least once (CI green, verified on a
|
||||||
|
device, shipped). Name that in `evidence`.
|
||||||
|
|
||||||
|
Any criterion left blank — or `applies_when`, `platforms` or `evidence`
|
||||||
|
left empty — VETOES the promotion on its own. A veto is not an error: the
|
||||||
|
idea stays a candidate and the veto is logged, so the next evaluation of
|
||||||
|
something like it sees why this one was held. That is also how you record
|
||||||
|
"evaluated, and it does not qualify": leave the failing criterion blank
|
||||||
|
and say why in `reason`.
|
||||||
|
|
||||||
|
Before deciding, read get_family_idea's `precedents`. The engine also
|
||||||
|
finds the nearest earlier decisions itself and stores them as consulted.
|
||||||
|
|
||||||
|
On promotion every project that is on one of `platforms` and that you can
|
||||||
|
write gets an `unassessed` row in its adoption ledger. If the idea was
|
||||||
|
canon before, its version moves, so earlier answers read as needing a
|
||||||
|
recheck.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: the record that carries the idea.
|
||||||
|
applies_when: when it applies, in platform terms.
|
||||||
|
platforms: platform slugs it is for (list_platforms).
|
||||||
|
platform_terms: why criterion 1 holds (blank = it does not).
|
||||||
|
platform_problem: why criterion 2 holds (blank = it does not).
|
||||||
|
proven: why criterion 3 holds (blank = it does not).
|
||||||
|
evidence: what proved it — a CI run, a task, a commit, a device check.
|
||||||
|
reason: the decision in one or two sentences.
|
||||||
|
precedent_ids: earlier family decisions you followed, if any.
|
||||||
|
"""
|
||||||
|
return await family_svc.promote(
|
||||||
|
current_user_id(), note_id,
|
||||||
|
applies_when=applies_when, platforms=platforms or [],
|
||||||
|
criteria={
|
||||||
|
"platform_terms": platform_terms,
|
||||||
|
"platform_problem": platform_problem,
|
||||||
|
"proven": proven,
|
||||||
|
},
|
||||||
|
evidence=evidence or [], reason=reason, precedent_ids=precedent_ids or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def retire_family_idea(note_id: int, reason: str) -> dict:
|
||||||
|
"""Demote a family idea — it no longer applies, or a better one replaces
|
||||||
|
it. Its history and every judged ledger answer are kept; rows nobody had
|
||||||
|
judged yet are closed. Undoable with undo_family_decision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: the idea.
|
||||||
|
reason: why it is retired.
|
||||||
|
"""
|
||||||
|
return await family_svc.retire(current_user_id(), note_id, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
async def undo_family_decision(decision_id: int, reason: str) -> dict:
|
||||||
|
"""Reverse a family decision — a promotion, a retirement or a proposal —
|
||||||
|
restoring the state it recorded as `before`. Only the latest idea-level
|
||||||
|
decision on an idea can be undone; the undo is logged too, so the history
|
||||||
|
keeps both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: the decision (list_family_decisions).
|
||||||
|
reason: why it is undone.
|
||||||
|
"""
|
||||||
|
return await family_svc.undo(current_user_id(), decision_id, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict:
|
||||||
|
"""The family decision log, newest first: every proposal, promotion,
|
||||||
|
veto, retirement and undo, with its reason, evidence and the precedents
|
||||||
|
it followed. `undoable` marks the decision an undo would reverse.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: only this idea's decisions. 0 = all.
|
||||||
|
limit / offset: page through the log.
|
||||||
|
"""
|
||||||
|
rows = await family_svc.list_decisions(
|
||||||
|
current_user_id(), idea_id=note_id or None,
|
||||||
|
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||||
|
)
|
||||||
|
return {"decisions": rows, "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp) -> None:
|
||||||
|
for fn in (
|
||||||
|
list_family_ideas, get_family_idea, propose_family_idea,
|
||||||
|
promote_family_idea, retire_family_idea, undo_family_decision,
|
||||||
|
list_family_decisions,
|
||||||
|
):
|
||||||
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
|
from scribe.services import family as family_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import task_logs as task_logs_svc
|
from scribe.services import task_logs as task_logs_svc
|
||||||
@@ -172,12 +173,20 @@ async def update_milestone(
|
|||||||
if order_index >= 0:
|
if order_index >= 0:
|
||||||
fields["order_index"] = order_index
|
fields["order_index"] = order_index
|
||||||
await refuse_guessed_ids(title, description, body)
|
await refuse_guessed_ids(title, description, body)
|
||||||
|
# Asked BEFORE the write: only the transition into done is a closing.
|
||||||
|
closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id)
|
||||||
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
|
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
|
||||||
if milestone is None:
|
if milestone is None:
|
||||||
raise ValueError(f"milestone {milestone_id} not found")
|
raise ValueError(f"milestone {milestone_id} not found")
|
||||||
|
data = milestone.to_dict()
|
||||||
|
if closing:
|
||||||
|
# Family canon's milestone trigger (milestone 463): a plan closing on
|
||||||
|
# a platform is the moment to ask whether what it built is shared.
|
||||||
|
hint = await family_svc.milestone_trigger(uid, milestone)
|
||||||
|
if hint:
|
||||||
|
data["family_hint"] = hint
|
||||||
return await moment_delivery.attach_moment_rules(
|
return await moment_delivery.attach_moment_rules(
|
||||||
uid, "update_milestone", {"status": status, "project_id": project_id},
|
uid, "update_milestone", {"status": status, "project_id": project_id}, data,
|
||||||
milestone.to_dict(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
|||||||
from scribe.mcp.tools import systems as systems_tools
|
from scribe.mcp.tools import systems as systems_tools
|
||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
|
from scribe.services import family as family_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import supersession as supersession_svc
|
from scribe.services import supersession as supersession_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
@@ -237,6 +238,9 @@ async def create_note(
|
|||||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||||
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
|
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
|
||||||
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
|
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
|
||||||
|
# Family canon's write-time triggers (milestone 463): a cited source of a
|
||||||
|
# pattern, or the same idea in another project on a shared platform.
|
||||||
|
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||||
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
|
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
|
||||||
|
|
||||||
|
|
||||||
@@ -319,6 +323,8 @@ async def update_note(
|
|||||||
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
|
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
|
||||||
)
|
)
|
||||||
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
|
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
|
||||||
|
if body:
|
||||||
|
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from scribe.mcp._context import current_user_id
|
|||||||
from scribe.mcp.tools import systems as systems_tools
|
from scribe.mcp.tools import systems as systems_tools
|
||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
|
from scribe.services import family as family_svc
|
||||||
from scribe.services import snippets as snippets_svc
|
from scribe.services import snippets as snippets_svc
|
||||||
from scribe.services.note_usage import attach_usage, record_pulled
|
from scribe.services.note_usage import attach_usage, record_pulled
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
@@ -224,6 +225,9 @@ async def create_snippet(
|
|||||||
advice = snippets_svc.trigger_advice(when_to_use)
|
advice = snippets_svc.trigger_advice(when_to_use)
|
||||||
if advice:
|
if advice:
|
||||||
data["trigger_advice"] = advice
|
data["trigger_advice"] = advice
|
||||||
|
# Family canon (milestone 463): the same shape recorded in another project
|
||||||
|
# on a shared platform opens an evaluation of the earlier one.
|
||||||
|
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||||
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
|
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from scribe.mcp._context import current_user_id
|
|||||||
from scribe.mcp.tools import systems as systems_tools
|
from scribe.mcp.tools import systems as systems_tools
|
||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
|
from scribe.services import family as family_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
||||||
@@ -363,6 +364,9 @@ async def create_task(
|
|||||||
data = note.to_dict()
|
data = note.to_dict()
|
||||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||||
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
|
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
|
||||||
|
# A task that says it is "matching" another project's work names the
|
||||||
|
# source of a pattern — family canon's citation trigger (milestone 463).
|
||||||
|
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||||
return await placement_svc.attach_placement(uid, data, note)
|
return await placement_svc.attach_placement(uid, data, note)
|
||||||
|
|
||||||
|
|
||||||
@@ -457,6 +461,8 @@ async def update_task(
|
|||||||
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
|
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
|
||||||
)
|
)
|
||||||
await placement_svc.attach_placement(uid, data, note)
|
await placement_svc.attach_placement(uid, data, note)
|
||||||
|
if body:
|
||||||
|
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||||
if status in _CLOSING_STATUSES:
|
if status in _CLOSING_STATUSES:
|
||||||
data["report_back"] = REPORT_BACK_CUE
|
data["report_back"] = REPORT_BACK_CUE
|
||||||
# The operator's own adjustments to the completion report, retrieved
|
# The operator's own adjustments to the completion report, retrieved
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Family canon routes — the web door to the promotion engine (milestone 463).
|
||||||
|
|
||||||
|
The agent decides promotions through the MCP tools; this door exists so a
|
||||||
|
person can READ every decision and its reasons, and retire an idea or undo a
|
||||||
|
decision when they disagree. The promote and propose endpoints are here for
|
||||||
|
parity with the agent's door (rule 33), and are recorded as the operator's.
|
||||||
|
Every write is gated on the idea's note in the service (rule 78).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from scribe.auth import get_current_user_id, login_required
|
||||||
|
from scribe.routes.utils import not_found
|
||||||
|
from scribe.services import family as family_svc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
family_bp = Blueprint("family", __name__, url_prefix="/api/family")
|
||||||
|
|
||||||
|
|
||||||
|
def _page() -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(request.args.get("limit", 50)), 200))
|
||||||
|
offset = max(0, int(request.args.get("offset", 0)))
|
||||||
|
except ValueError:
|
||||||
|
limit, offset = 50, 0
|
||||||
|
return limit, offset
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/ideas", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def list_ideas_route():
|
||||||
|
limit, offset = _page()
|
||||||
|
ideas = await family_svc.list_ideas(
|
||||||
|
get_current_user_id(),
|
||||||
|
status=request.args.get("status") or None,
|
||||||
|
platform=request.args.get("platform") or None,
|
||||||
|
limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)})
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/ideas/<int:note_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def get_idea_route(note_id: int):
|
||||||
|
uid = get_current_user_id()
|
||||||
|
idea = await family_svc.get_idea(uid, note_id)
|
||||||
|
if idea is None:
|
||||||
|
return not_found("Family idea")
|
||||||
|
idea["precedents"] = await family_svc.precedents(uid, note_id)
|
||||||
|
return jsonify(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/ideas/<int:note_id>/propose", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
async def propose_route(note_id: int):
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
try:
|
||||||
|
idea, created = await family_svc.propose(
|
||||||
|
get_current_user_id(), note_id, reason=data.get("reason") or "",
|
||||||
|
applies_when=data.get("applies_when") or None, decided_via="operator",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify({"idea": idea, "created": created}), (201 if created else 200)
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/ideas/<int:note_id>/promote", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
async def promote_route(note_id: int):
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
try:
|
||||||
|
result = await family_svc.promote(
|
||||||
|
get_current_user_id(), note_id,
|
||||||
|
applies_when=data.get("applies_when") or "",
|
||||||
|
platforms=data.get("platforms") or [],
|
||||||
|
criteria=data.get("criteria") or {},
|
||||||
|
evidence=data.get("evidence") or [],
|
||||||
|
reason=data.get("reason") or "",
|
||||||
|
precedent_ids=data.get("precedent_ids") or [],
|
||||||
|
decided_via="operator",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/ideas/<int:note_id>/retire", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
async def retire_route(note_id: int):
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
try:
|
||||||
|
result = await family_svc.retire(
|
||||||
|
get_current_user_id(), note_id, reason=data.get("reason") or "",
|
||||||
|
decided_via="operator",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/decisions", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def list_decisions_route():
|
||||||
|
limit, offset = _page()
|
||||||
|
idea_id = request.args.get("idea_id", type=int)
|
||||||
|
rows = await family_svc.list_decisions(
|
||||||
|
get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
|
||||||
|
|
||||||
|
|
||||||
|
@family_bp.route("/decisions/<int:decision_id>/undo", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
async def undo_route(decision_id: int):
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
try:
|
||||||
|
result = await family_svc.undo(
|
||||||
|
get_current_user_id(), decision_id, reason=data.get("reason") or "",
|
||||||
|
decided_via="operator",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify(result)
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
"""Family canon's promotion engine (milestone 463 step 3).
|
||||||
|
|
||||||
|
A family idea moves between three states — candidate, canon, retired — and
|
||||||
|
every move is a decision with a reason, written to `family_decisions`. No
|
||||||
|
person approves a promotion: the agent decides against the written criteria
|
||||||
|
below, and the log is what keeps one decision consistent with the last similar
|
||||||
|
one, and what lets a person read or undo any of them afterwards.
|
||||||
|
|
||||||
|
WHO DECIDES WHAT
|
||||||
|
|
||||||
|
- TRIGGERS (`citation_trigger`, `repeat_trigger`, `milestone_trigger`) only
|
||||||
|
ever OPEN an evaluation. The first two record the source record as a
|
||||||
|
`candidate` (decided_via "system") and hand the writer an in-band hint; a
|
||||||
|
closed milestone is not a record an idea can hang on, so it only hints. None
|
||||||
|
of them promotes.
|
||||||
|
- THE AGENT evaluates a candidate against the three criteria and either
|
||||||
|
promotes it or leaves it a candidate with the reason. A criterion with no
|
||||||
|
support vetoes on its own; the veto is logged too, because a held candidate
|
||||||
|
is precedent for the next one like it.
|
||||||
|
- A PERSON reads the log, and may retire an idea or undo a decision from the
|
||||||
|
web door. Those are the only operator acts, and neither is required.
|
||||||
|
|
||||||
|
PRECEDENT
|
||||||
|
|
||||||
|
Every promotion records the earlier decisions it was consistent with. The
|
||||||
|
engine finds them itself — the decisions on the ideas nearest this one by
|
||||||
|
meaning — and stores them alongside any the caller names, so "which precedents
|
||||||
|
were consulted" is a fact about the call, not a sentence a session might skip.
|
||||||
|
|
||||||
|
LEDGER ROWS AND UNDO (settled here, as the milestone asked)
|
||||||
|
|
||||||
|
Promotion opens an `unassessed` row for every project that is a member of one
|
||||||
|
of the idea's platforms and that the promoter can write. Leaving canon —
|
||||||
|
by retirement, or by undoing the promotion — deletes the `unassessed` rows,
|
||||||
|
because nobody judged them and they would only be noise. Rows somebody DID
|
||||||
|
judge (adopted, variant, exempt, owed) are kept: each is a decision with its
|
||||||
|
reason, and if the idea is promoted again its version moves, so every kept
|
||||||
|
row reads as needing a recheck rather than as still agreeing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.family import (
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, Platform,
|
||||||
|
ProjectPlatform,
|
||||||
|
)
|
||||||
|
from scribe.models.note import Note
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.services import access
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- the criteria, as product text --------------------------------------------
|
||||||
|
#
|
||||||
|
# These are what an evaluation is judged against, on every install. They live
|
||||||
|
# here — and in the tool docstrings that quote them — rather than in any
|
||||||
|
# instance's rulebook (rules 115, 119).
|
||||||
|
|
||||||
|
CRITERIA = (
|
||||||
|
{
|
||||||
|
"key": "platform_terms",
|
||||||
|
"title": "Stated in platform terms",
|
||||||
|
"test": (
|
||||||
|
"Its 'when it applies' is stated in terms of a platform — what the "
|
||||||
|
"project is built on or ships as — not one app's domain. Any project "
|
||||||
|
"on the platform could read it and know whether it applies."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "platform_problem",
|
||||||
|
"title": "Answers a platform problem or a cross-project stance",
|
||||||
|
"test": (
|
||||||
|
"It answers a problem the platform itself causes, or a stance the "
|
||||||
|
"operator holds across projects. One app's preference does not "
|
||||||
|
"qualify."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "proven",
|
||||||
|
"title": "Proven at least once",
|
||||||
|
"test": (
|
||||||
|
"It has worked for real at least once — CI green, verified on a "
|
||||||
|
"device, or shipped — and the evidence is named. An unproven idea "
|
||||||
|
"stays a candidate."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CRITERIA_KEYS = tuple(c["key"] for c in CRITERIA)
|
||||||
|
|
||||||
|
TRIGGERS = ("citation", "repeat", "milestone")
|
||||||
|
|
||||||
|
# The repeat trigger's similarity floor. MEASURED, not guessed (2026-10-06,
|
||||||
|
# #4989): against a description of one pattern known to have been built four
|
||||||
|
# times in four projects, the records that implement it scored 0.79-0.82, and
|
||||||
|
# the best match in a project that never built it scored 0.65. A trigger only
|
||||||
|
# opens an evaluation, so a false positive costs one judgment, not a wrong
|
||||||
|
# promotion. Step 5 measures the cross-language threshold against known pairs
|
||||||
|
# and supersedes this.
|
||||||
|
REPEAT_THRESHOLD = 0.80
|
||||||
|
|
||||||
|
# Words that say a citation is the SOURCE of a pattern rather than background.
|
||||||
|
# "see #12" is a pointer; "matching #12" and "ported from #12" are lineage.
|
||||||
|
_LINEAGE = re.compile(
|
||||||
|
r"\b(match(?:es|ing)?|mirror(?:s|ing|ed)?|same (?:shape|pattern|approach|design) as|"
|
||||||
|
r"ported from|copied from|borrowed from|lifted from|taken from|based on|"
|
||||||
|
r"modell?ed on|follow(?:s|ing)? the (?:shape|pattern|approach) of|as (?:built|done) in|"
|
||||||
|
r"reuses?|re-?implement(?:s|ing)?|like)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
# How far before a `#N` the lineage word may sit: the same clause, roughly.
|
||||||
|
_LINEAGE_WINDOW = 80
|
||||||
|
|
||||||
|
# Note kinds the repeat trigger compares: recorded knowledge and shapes, not
|
||||||
|
# the to-do list.
|
||||||
|
_REPEAT_KINDS = ("snippet", "note")
|
||||||
|
|
||||||
|
MEMBER_STATES = ("declared", "detected")
|
||||||
|
_IDEA_ACTIONS = ("propose", "promote", "revise", "retire")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# --- reads --------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _platform_slugs(session, note_id: int) -> list[str]:
|
||||||
|
rows = await session.execute(
|
||||||
|
select(Platform.slug)
|
||||||
|
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == Platform.id)
|
||||||
|
.where(FamilyIdeaPlatform.note_id == note_id)
|
||||||
|
.order_by(Platform.order_index.asc(), Platform.slug.asc())
|
||||||
|
)
|
||||||
|
return list(rows.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
|
||||||
|
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
|
||||||
|
inside JSON cannot be remapped by a restore (the #3182 trap)."""
|
||||||
|
if idea is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"status": idea.status,
|
||||||
|
"applies_when": idea.applies_when or "",
|
||||||
|
"canon_version": idea.canon_version,
|
||||||
|
"platforms": await _platform_slugs(session, idea.note_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_dict(d: FamilyDecision, title: str | None = None) -> dict:
|
||||||
|
out = d.to_dict()
|
||||||
|
if title is not None:
|
||||||
|
out["idea_title"] = title
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def get_idea(user_id: int, note_id: int) -> dict | None:
|
||||||
|
"""One idea with everything an evaluation reads: its state, platforms,
|
||||||
|
decision history (newest first), its ledger counts and the criteria.
|
||||||
|
None when the caller cannot read the note or it is not an idea."""
|
||||||
|
if not await access.can_read_note(user_id, note_id):
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
idea = await session.get(FamilyIdea, note_id)
|
||||||
|
note = await session.get(Note, note_id)
|
||||||
|
if idea is None or note is None:
|
||||||
|
return None
|
||||||
|
decisions = (await session.execute(
|
||||||
|
select(FamilyDecision).where(FamilyDecision.idea_id == note_id)
|
||||||
|
.order_by(FamilyDecision.id.desc())
|
||||||
|
)).scalars().all()
|
||||||
|
counts = dict((await session.execute(
|
||||||
|
select(FamilyAdoption.status, func.count())
|
||||||
|
.where(FamilyAdoption.idea_id == note_id)
|
||||||
|
.group_by(FamilyAdoption.status)
|
||||||
|
)).all())
|
||||||
|
out = idea.to_dict()
|
||||||
|
out.update({
|
||||||
|
"title": note.title,
|
||||||
|
"note_type": note.note_type or "note",
|
||||||
|
"is_task": note.is_task,
|
||||||
|
"project_id": note.project_id,
|
||||||
|
"platforms": await _platform_slugs(session, note_id),
|
||||||
|
"adoptions": counts,
|
||||||
|
"decisions": [_decision_dict(d) for d in decisions],
|
||||||
|
"undoable_decision_id": _undoable_id(decisions),
|
||||||
|
})
|
||||||
|
out["criteria"] = list(CRITERIA)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_ideas(
|
||||||
|
user_id: int, *, status: str | None = None, platform: str | None = None,
|
||||||
|
limit: int = 50, offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Ideas whose note the caller can read, newest change first."""
|
||||||
|
async with async_session() as session:
|
||||||
|
query = (
|
||||||
|
select(FamilyIdea, Note.title, Note.note_type, Note.project_id, Note.status)
|
||||||
|
.join(Note, Note.id == FamilyIdea.note_id)
|
||||||
|
.where(access.readable_notes_clause(user_id), Note.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
query = query.where(FamilyIdea.status == status)
|
||||||
|
if platform:
|
||||||
|
query = query.where(FamilyIdea.note_id.in_(
|
||||||
|
select(FamilyIdeaPlatform.note_id)
|
||||||
|
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
|
||||||
|
.where(Platform.slug == platform)
|
||||||
|
))
|
||||||
|
rows = (await session.execute(
|
||||||
|
query.order_by(FamilyIdea.updated_at.desc()).limit(limit).offset(offset)
|
||||||
|
)).all()
|
||||||
|
out = []
|
||||||
|
for idea, title, note_type, project_id, note_status in rows:
|
||||||
|
d = idea.to_dict()
|
||||||
|
d.update({
|
||||||
|
"title": title,
|
||||||
|
"note_type": note_type or "note",
|
||||||
|
"is_task": note_status is not None,
|
||||||
|
"project_id": project_id,
|
||||||
|
"platforms": await _platform_slugs(session, idea.note_id),
|
||||||
|
})
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_decisions(
|
||||||
|
user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The decision log, newest first, over ideas the caller can read. Each
|
||||||
|
row says whether it is the one an undo would reverse."""
|
||||||
|
async with async_session() as session:
|
||||||
|
query = (
|
||||||
|
select(FamilyDecision, Note.title)
|
||||||
|
.join(Note, Note.id == FamilyDecision.idea_id)
|
||||||
|
.where(access.readable_notes_clause(user_id))
|
||||||
|
)
|
||||||
|
if idea_id:
|
||||||
|
query = query.where(FamilyDecision.idea_id == idea_id)
|
||||||
|
rows = (await session.execute(
|
||||||
|
query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset)
|
||||||
|
)).all()
|
||||||
|
latest = await _latest_idea_decisions(session, {d.idea_id for d, _ in rows})
|
||||||
|
out = []
|
||||||
|
for d, title in rows:
|
||||||
|
item = _decision_dict(d, title)
|
||||||
|
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _can_undo(d: FamilyDecision) -> bool:
|
||||||
|
"""An idea-level decision that changed something. A veto (a `propose`
|
||||||
|
whose before and after agree) changed nothing, and an undo is undone by
|
||||||
|
deciding again, not by undoing the undo."""
|
||||||
|
return d.project_id is None and d.action in _IDEA_ACTIONS and d.before != d.after
|
||||||
|
|
||||||
|
|
||||||
|
def _undoable_id(decisions_newest_first) -> int | None:
|
||||||
|
for d in decisions_newest_first:
|
||||||
|
if d.project_id is None:
|
||||||
|
return d.id if _can_undo(d) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||||
|
if not idea_ids:
|
||||||
|
return {}
|
||||||
|
rows = await session.execute(
|
||||||
|
select(FamilyDecision.idea_id, func.max(FamilyDecision.id))
|
||||||
|
.where(FamilyDecision.idea_id.in_(idea_ids), FamilyDecision.project_id.is_(None))
|
||||||
|
.group_by(FamilyDecision.idea_id)
|
||||||
|
)
|
||||||
|
return dict(rows.all())
|
||||||
|
|
||||||
|
|
||||||
|
async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
|
||||||
|
"""The decisions on the ideas nearest this one by meaning — what a new
|
||||||
|
decision about it should be consistent with. Each idea contributes its
|
||||||
|
latest idea-level decision. Empty when nothing similar has been decided,
|
||||||
|
or when the embedder is unavailable."""
|
||||||
|
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
note = await session.get(Note, note_id)
|
||||||
|
if note is None:
|
||||||
|
return []
|
||||||
|
idea_ids = set((await session.execute(select(FamilyIdea.note_id))).scalars().all())
|
||||||
|
idea_ids.discard(note_id)
|
||||||
|
if not idea_ids:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
hits = await semantic_search_notes(
|
||||||
|
user_id, embedding_text(note.title, note.body), exclude_ids={note_id},
|
||||||
|
limit=40, threshold=0.0, scope="read", include_global_kinds=True,
|
||||||
|
demote_superseded=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("precedent search failed for idea %s", note_id, exc_info=True)
|
||||||
|
return []
|
||||||
|
ranked = [(score, n) for score, n in hits if n.id in idea_ids][:limit]
|
||||||
|
if not ranked:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
latest = await _latest_idea_decisions(session, {n.id for _, n in ranked})
|
||||||
|
decisions = {
|
||||||
|
d.id: d for d in (await session.execute(
|
||||||
|
select(FamilyDecision).where(FamilyDecision.id.in_(list(latest.values())))
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
out = []
|
||||||
|
for score, n in ranked:
|
||||||
|
d = decisions.get(latest.get(n.id))
|
||||||
|
if d is not None:
|
||||||
|
item = _decision_dict(d, n.title)
|
||||||
|
item["similarity"] = round(float(score), 3)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- writes ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _log(session, *, idea_id: int, action: str, reason: str, before, after,
|
||||||
|
evidence: dict | None, precedent_ids: list[int] | None, decided_via: str,
|
||||||
|
user_id: int | None, project_id: int | None = None) -> FamilyDecision:
|
||||||
|
row = FamilyDecision(
|
||||||
|
idea_id=idea_id, project_id=project_id, action=action, reason=reason.strip(),
|
||||||
|
before=before, after=after, evidence=evidence or {},
|
||||||
|
precedent_ids=list(dict.fromkeys(precedent_ids or [])),
|
||||||
|
decided_via=decided_via, user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_platforms(session, slugs: list[str]) -> dict[str, int]:
|
||||||
|
wanted = list(dict.fromkeys(s.strip() for s in slugs if s and s.strip()))
|
||||||
|
if not wanted:
|
||||||
|
return {}
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Platform.slug, Platform.id)
|
||||||
|
.where(Platform.slug.in_(wanted), Platform.deleted_at.is_(None))
|
||||||
|
)).all()
|
||||||
|
known = dict(rows)
|
||||||
|
unknown = [s for s in wanted if s not in known]
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||||
|
return known
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_platforms(session, note_id: int, platform_ids) -> None:
|
||||||
|
await session.execute(delete(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id == note_id))
|
||||||
|
for pid in dict.fromkeys(platform_ids):
|
||||||
|
session.add(FamilyIdeaPlatform(note_id=note_id, platform_id=pid))
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_ledger(session, user_id: int, note_id: int) -> int:
|
||||||
|
"""An `unassessed` row for every member project of the idea's platforms
|
||||||
|
that the promoter can write and that has no row yet. Returns how many."""
|
||||||
|
project_ids = set((await session.execute(
|
||||||
|
select(ProjectPlatform.project_id)
|
||||||
|
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
|
||||||
|
.join(Project, Project.id == ProjectPlatform.project_id)
|
||||||
|
.where(
|
||||||
|
FamilyIdeaPlatform.note_id == note_id,
|
||||||
|
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
answered = set((await session.execute(
|
||||||
|
select(FamilyAdoption.project_id).where(FamilyAdoption.idea_id == note_id)
|
||||||
|
)).scalars().all())
|
||||||
|
opened = 0
|
||||||
|
for pid in sorted(project_ids - answered):
|
||||||
|
# Rule 78: a promotion reaches only the projects its promoter could
|
||||||
|
# have written an answer into themselves.
|
||||||
|
if await access.can_write_project(user_id, pid):
|
||||||
|
session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed"))
|
||||||
|
opened += 1
|
||||||
|
return opened
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_unassessed(session, note_id: int) -> int:
|
||||||
|
result = await session.execute(
|
||||||
|
delete(FamilyAdoption).where(
|
||||||
|
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def propose(
|
||||||
|
user_id: int, note_id: int, *, reason: str, trigger: str = "agent",
|
||||||
|
evidence: dict | None = None, applies_when: str | None = None,
|
||||||
|
decided_via: str = "agent",
|
||||||
|
) -> tuple[dict, bool]:
|
||||||
|
"""Record a note as a family-idea CANDIDATE. Returns (idea, created).
|
||||||
|
|
||||||
|
Idempotent: an existing idea comes back unchanged and nothing is logged —
|
||||||
|
a trigger firing on every edit of a record must not grow the log. Write-
|
||||||
|
gated on the note: an idea is state on someone's record (rule 78).
|
||||||
|
"""
|
||||||
|
if not (reason or "").strip():
|
||||||
|
raise ValueError("a proposal needs a reason — what makes this a family idea?")
|
||||||
|
if not await access.can_write_note(user_id, note_id):
|
||||||
|
raise ValueError(f"note {note_id} not found or no write access")
|
||||||
|
async with async_session() as session:
|
||||||
|
idea = await session.get(FamilyIdea, note_id)
|
||||||
|
if idea is not None:
|
||||||
|
return idea.to_dict(), False
|
||||||
|
idea = FamilyIdea(note_id=note_id, status="candidate",
|
||||||
|
applies_when=(applies_when or "").strip() or None)
|
||||||
|
session.add(idea)
|
||||||
|
await session.flush()
|
||||||
|
_log(
|
||||||
|
session, idea_id=note_id, action="propose", reason=reason, before=None,
|
||||||
|
after=await _snapshot(session, idea),
|
||||||
|
evidence={"trigger": trigger, **(evidence or {})},
|
||||||
|
precedent_ids=None, decided_via=decided_via, user_id=user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return idea.to_dict(), True
|
||||||
|
|
||||||
|
|
||||||
|
def vetoes(*, applies_when: str, platforms: list[str], criteria: dict, evidence: list) -> list[str]:
|
||||||
|
"""The criteria a promotion fails, by key. Pure.
|
||||||
|
|
||||||
|
Each fails ON ITS OWN when its support is missing:
|
||||||
|
- platform_terms — no reasoning, no `applies_when`, or no platform scope;
|
||||||
|
- platform_problem — no reasoning;
|
||||||
|
- proven — no reasoning, or no named evidence.
|
||||||
|
"""
|
||||||
|
def said(key: str) -> bool:
|
||||||
|
return bool(str(criteria.get(key) or "").strip())
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
if not said("platform_terms") or not (applies_when or "").strip() or not platforms:
|
||||||
|
failed.append("platform_terms")
|
||||||
|
if not said("platform_problem"):
|
||||||
|
failed.append("platform_problem")
|
||||||
|
if not said("proven") or not [e for e in evidence or [] if str(e).strip()]:
|
||||||
|
failed.append("proven")
|
||||||
|
return failed
|
||||||
|
|
||||||
|
|
||||||
|
async def promote(
|
||||||
|
user_id: int, note_id: int, *, applies_when: str, platforms: list[str],
|
||||||
|
criteria: dict, evidence: list[str], reason: str,
|
||||||
|
precedent_ids: list[int] | None = None, decided_via: str = "agent",
|
||||||
|
) -> dict:
|
||||||
|
"""Evaluate a record against the three criteria and promote it to canon.
|
||||||
|
|
||||||
|
A record nobody proposed may be promoted directly — the candidate row is
|
||||||
|
created on the way. Any criterion without support vetoes the promotion:
|
||||||
|
the idea stays (or becomes) a candidate and the veto is logged as a
|
||||||
|
`propose` decision naming what failed, so it is precedent too.
|
||||||
|
|
||||||
|
On promotion: status canon, `applies_when` and the platform scope set, the
|
||||||
|
version bumped if this idea has been promoted before (so rows judged
|
||||||
|
against the earlier canon read as needing a recheck), an `unassessed`
|
||||||
|
ledger row opened per member project, and a decision logged with the
|
||||||
|
criteria reasoning, the evidence and the precedents consulted.
|
||||||
|
|
||||||
|
Raises ValueError on a malformed call (no reason, unknown platform, no
|
||||||
|
write access, already canon) — before anything is written.
|
||||||
|
"""
|
||||||
|
if not (reason or "").strip():
|
||||||
|
raise ValueError("a promotion needs a reason")
|
||||||
|
if not await access.can_write_note(user_id, note_id):
|
||||||
|
raise ValueError(f"note {note_id} not found or no write access")
|
||||||
|
criteria = {k: str((criteria or {}).get(k) or "").strip() for k in CRITERIA_KEYS}
|
||||||
|
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
|
||||||
|
consulted = await precedents(user_id, note_id)
|
||||||
|
named = await _existing_decision_ids(precedent_ids or [])
|
||||||
|
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
known = await _resolve_platforms(session, platforms or [])
|
||||||
|
idea = await session.get(FamilyIdea, note_id)
|
||||||
|
if idea is not None and idea.status == "canon":
|
||||||
|
raise ValueError(
|
||||||
|
f"#{note_id} is already canon (version {idea.canon_version}); "
|
||||||
|
"retire it first, or record a revision"
|
||||||
|
)
|
||||||
|
created_now = idea is None
|
||||||
|
if created_now:
|
||||||
|
idea = FamilyIdea(note_id=note_id, status="candidate")
|
||||||
|
session.add(idea)
|
||||||
|
await session.flush()
|
||||||
|
# A row made by this call had no prior state: `before` says so, which
|
||||||
|
# also makes a veto that created a candidate undoable (it changed
|
||||||
|
# something — a record became a candidate).
|
||||||
|
before = None if created_now else await _snapshot(session, idea)
|
||||||
|
failed = vetoes(
|
||||||
|
applies_when=applies_when, platforms=list(known), criteria=criteria,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
record = {"criteria": criteria, "evidence": evidence}
|
||||||
|
if failed:
|
||||||
|
decision = _log(
|
||||||
|
session, idea_id=note_id, action="propose",
|
||||||
|
reason=f"held as a candidate — fails {', '.join(failed)}: {reason.strip()}",
|
||||||
|
before=before, after=await _snapshot(session, idea),
|
||||||
|
evidence={**record, "vetoed_by": failed},
|
||||||
|
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(decision)
|
||||||
|
return {
|
||||||
|
"promoted": False, "vetoed_by": failed, "idea": idea.to_dict(),
|
||||||
|
"decision": decision.to_dict(), "precedents": consulted,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-promotion moves the version PAST every version this idea has ever
|
||||||
|
# held — including one an undo rolled back from — so no row judged
|
||||||
|
# against an earlier canon can read as agreeing with this one.
|
||||||
|
history = (await session.execute(
|
||||||
|
select(FamilyDecision.action, FamilyDecision.after)
|
||||||
|
.where(FamilyDecision.idea_id == note_id)
|
||||||
|
)).all()
|
||||||
|
if any(action == "promote" for action, _ in history):
|
||||||
|
seen = [idea.canon_version or 1] + [
|
||||||
|
int(after.get("canon_version") or 1) for _, after in history if after
|
||||||
|
]
|
||||||
|
idea.canon_version = max(seen) + 1
|
||||||
|
idea.status = "canon"
|
||||||
|
idea.applies_when = applies_when.strip()
|
||||||
|
idea.updated_at = _now()
|
||||||
|
await _set_platforms(session, note_id, known.values())
|
||||||
|
await session.flush()
|
||||||
|
opened = await _open_ledger(session, user_id, note_id)
|
||||||
|
decision = _log(
|
||||||
|
session, idea_id=note_id, action="promote", reason=reason,
|
||||||
|
before=before, after=await _snapshot(session, idea),
|
||||||
|
evidence={**record, "ledger_rows_opened": opened},
|
||||||
|
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(decision)
|
||||||
|
return {
|
||||||
|
"promoted": True, "idea": idea.to_dict(), "ledger_rows_opened": opened,
|
||||||
|
"decision": decision.to_dict(), "precedents": consulted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||||
|
ids = [int(i) for i in ids if i]
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
found = set((await session.execute(
|
||||||
|
select(FamilyDecision.id).where(FamilyDecision.id.in_(ids))
|
||||||
|
)).scalars().all())
|
||||||
|
missing = [i for i in ids if i not in found]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"no such family decision(s): {', '.join(map(str, missing))}")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str = "agent") -> dict:
|
||||||
|
"""Demote an idea. Its history and its judged ledger rows are kept; the
|
||||||
|
rows nobody judged are closed. Undoable."""
|
||||||
|
if not (reason or "").strip():
|
||||||
|
raise ValueError("retiring an idea needs a reason")
|
||||||
|
if not await access.can_write_note(user_id, note_id):
|
||||||
|
raise ValueError(f"note {note_id} not found or no write access")
|
||||||
|
async with async_session() as session:
|
||||||
|
idea = await session.get(FamilyIdea, note_id)
|
||||||
|
if idea is None:
|
||||||
|
raise ValueError(f"#{note_id} is not a family idea")
|
||||||
|
if idea.status == "retired":
|
||||||
|
raise ValueError(f"#{note_id} is already retired")
|
||||||
|
before = await _snapshot(session, idea)
|
||||||
|
idea.status = "retired"
|
||||||
|
idea.updated_at = _now()
|
||||||
|
closed = await _close_unassessed(session, note_id)
|
||||||
|
decision = _log(
|
||||||
|
session, idea_id=note_id, action="retire", reason=reason, before=before,
|
||||||
|
after=await _snapshot(session, idea), evidence={"ledger_rows_closed": closed},
|
||||||
|
precedent_ids=None, decided_via=decided_via, user_id=user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(decision)
|
||||||
|
return {"idea": idea.to_dict(), "decision": decision.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str = "agent") -> dict:
|
||||||
|
"""Reverse an idea-level decision, restoring the state it recorded as
|
||||||
|
`before`. Only the LATEST idea-level decision on an idea can be undone —
|
||||||
|
undoing an older one would rewrite a state later decisions were built on.
|
||||||
|
The undo is itself a decision, naming the one it reverses as its
|
||||||
|
precedent, so the history keeps both.
|
||||||
|
|
||||||
|
Undoing the proposal that created an idea retires it rather than deleting
|
||||||
|
it: deleting the idea would take its decision log with it.
|
||||||
|
"""
|
||||||
|
if not (reason or "").strip():
|
||||||
|
raise ValueError("an undo needs a reason")
|
||||||
|
async with async_session() as session:
|
||||||
|
target = await session.get(FamilyDecision, decision_id)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"no such family decision: {decision_id}")
|
||||||
|
if not await access.can_write_note(user_id, target.idea_id):
|
||||||
|
raise ValueError(f"decision {decision_id} not found or no write access")
|
||||||
|
async with async_session() as session:
|
||||||
|
latest_id = (await _latest_idea_decisions(session, {target.idea_id})).get(target.idea_id)
|
||||||
|
if latest_id != target.id or not _can_undo(target):
|
||||||
|
if not _can_undo(target):
|
||||||
|
why = ("it changed nothing" if target.before == target.after
|
||||||
|
else f"a '{target.action}' decision is not undone this way")
|
||||||
|
else:
|
||||||
|
why = f"decision {latest_id} came after it — undo that one first"
|
||||||
|
raise ValueError(f"decision {decision_id} cannot be undone: {why}")
|
||||||
|
idea = await session.get(FamilyIdea, target.idea_id)
|
||||||
|
before = await _snapshot(session, idea)
|
||||||
|
prior = target.before or {
|
||||||
|
"status": "retired", "applies_when": idea.applies_when or "",
|
||||||
|
"canon_version": idea.canon_version, "platforms": before["platforms"],
|
||||||
|
}
|
||||||
|
if prior["status"] == "canon" and not (prior.get("applies_when") or "").strip():
|
||||||
|
raise ValueError("the recorded prior state is canon with no 'applies when'")
|
||||||
|
known = await _resolve_platforms(session, prior.get("platforms") or [])
|
||||||
|
idea.status = prior["status"]
|
||||||
|
idea.applies_when = (prior.get("applies_when") or "").strip() or None
|
||||||
|
idea.canon_version = prior.get("canon_version") or idea.canon_version
|
||||||
|
idea.updated_at = _now()
|
||||||
|
await _set_platforms(session, idea.note_id, known.values())
|
||||||
|
await session.flush()
|
||||||
|
ledger: dict = {}
|
||||||
|
if idea.status == "canon":
|
||||||
|
ledger["ledger_rows_opened"] = await _open_ledger(session, user_id, idea.note_id)
|
||||||
|
else:
|
||||||
|
ledger["ledger_rows_closed"] = await _close_unassessed(session, idea.note_id)
|
||||||
|
decision = _log(
|
||||||
|
session, idea_id=idea.note_id, action="undo", reason=reason,
|
||||||
|
before=before, after=await _snapshot(session, idea),
|
||||||
|
evidence={"undid_action": target.action, **ledger},
|
||||||
|
precedent_ids=[target.id], decided_via=decided_via, user_id=user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(decision)
|
||||||
|
return {"idea": idea.to_dict(), "decision": decision.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
# --- triggers -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lineage_citations(text: str | None) -> list[int]:
|
||||||
|
"""The `#N`s in a text that are cited as the SOURCE of a pattern — a
|
||||||
|
lineage word ("matching", "ported from", "same shape as", …) in the same
|
||||||
|
clause just before the reference. Pure, in order, de-duplicated."""
|
||||||
|
from scribe.services.record_refs import REF_RE
|
||||||
|
|
||||||
|
found: list[int] = []
|
||||||
|
for m in REF_RE.finditer(text or ""):
|
||||||
|
start = max(0, m.start() - _LINEAGE_WINDOW)
|
||||||
|
window = text[start:m.start()]
|
||||||
|
window = re.split(r"[\n.;]", window)[-1]
|
||||||
|
if _LINEAGE.search(window):
|
||||||
|
n = int(m.group(1))
|
||||||
|
if n not in found:
|
||||||
|
found.append(n)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
async def _member_platform_ids(session, project_id: int) -> set[int]:
|
||||||
|
return set((await session.execute(
|
||||||
|
select(ProjectPlatform.platform_id).where(
|
||||||
|
ProjectPlatform.project_id == project_id,
|
||||||
|
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_line(note_id: int) -> str:
|
||||||
|
return (
|
||||||
|
f"Evaluate it now: get_family_idea({note_id}) shows the three criteria and "
|
||||||
|
"the nearest precedents; then promote_family_idea if all three hold, or "
|
||||||
|
"leave it a candidate (promote_family_idea records which criterion "
|
||||||
|
"failed). No one approves this — the criteria decide."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def citation_trigger(user_id: int, note) -> str | None:
|
||||||
|
"""A record that cites ANOTHER project's record as the source of its
|
||||||
|
pattern opens an evaluation of that source as a family idea. Silent on a
|
||||||
|
citation without lineage words, and on a citation within one project."""
|
||||||
|
if not getattr(note, "project_id", None):
|
||||||
|
return None
|
||||||
|
cited = [n for n in lineage_citations(note.body) if n != note.id]
|
||||||
|
if not cited:
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Note.id, Note.title, Note.project_id, Project.title)
|
||||||
|
.join(Project, Project.id == Note.project_id)
|
||||||
|
.where(
|
||||||
|
Note.id.in_(cited), Note.deleted_at.is_(None),
|
||||||
|
Note.project_id.is_not(None), Note.project_id != note.project_id,
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
for source_id, source_title, _pid, project_title in sorted(rows, key=lambda r: cited.index(r[0])):
|
||||||
|
if not await access.can_write_note(user_id, source_id):
|
||||||
|
continue
|
||||||
|
idea, created = await propose(
|
||||||
|
user_id, source_id, trigger="citation", decided_via="system",
|
||||||
|
reason=f"#{note.id} cites it as the source of its pattern, across projects",
|
||||||
|
evidence={"cited_by": {"id": note.id, "title": note.title}},
|
||||||
|
)
|
||||||
|
if idea["status"] == "canon":
|
||||||
|
return (
|
||||||
|
f"This record follows #{source_id} \"{source_title}\" ({project_title}), "
|
||||||
|
"which is already family canon. This project answers it through "
|
||||||
|
"its adoption ledger rather than by copying it."
|
||||||
|
)
|
||||||
|
lead = "is now a family-idea candidate" if created else "is already a candidate"
|
||||||
|
return (
|
||||||
|
f"This record cites #{source_id} \"{source_title}\" ({project_title}) as "
|
||||||
|
f"the source of its pattern — an idea carried between projects, which "
|
||||||
|
f"{lead}. {_evaluate_line(source_id)}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def repeat_trigger(user_id: int, note) -> str | None:
|
||||||
|
"""A new record whose meaning repeats a record in ANOTHER project that
|
||||||
|
shares a platform with this one opens an evaluation of the earlier record.
|
||||||
|
Silent when the projects share no platform, below the threshold, or when
|
||||||
|
the project has no platforms yet."""
|
||||||
|
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||||
|
|
||||||
|
if not getattr(note, "project_id", None) or note.is_task:
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
mine = await _member_platform_ids(session, note.project_id)
|
||||||
|
if not mine:
|
||||||
|
return None
|
||||||
|
hits = await semantic_search_notes(
|
||||||
|
user_id, embedding_text(note.title, note.body), exclude_ids={note.id},
|
||||||
|
limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=_REPEAT_KINDS,
|
||||||
|
is_task=False, demote_superseded=False,
|
||||||
|
)
|
||||||
|
for score, other in hits:
|
||||||
|
if not other.project_id or other.project_id == note.project_id:
|
||||||
|
continue
|
||||||
|
async with async_session() as session:
|
||||||
|
shared = mine & await _member_platform_ids(session, other.project_id)
|
||||||
|
if not shared or not await access.can_write_note(user_id, other.id):
|
||||||
|
continue
|
||||||
|
idea, created = await propose(
|
||||||
|
user_id, other.id, trigger="repeat", decided_via="system",
|
||||||
|
reason=(f"#{note.id} repeats it in another project on a shared platform "
|
||||||
|
f"(similarity {score:.2f})"),
|
||||||
|
evidence={"repeated_by": {"id": note.id, "title": note.title},
|
||||||
|
"similarity": round(float(score), 3)},
|
||||||
|
)
|
||||||
|
if idea["status"] == "canon":
|
||||||
|
return (
|
||||||
|
f"This record repeats #{other.id} \"{other.title}\", which is already "
|
||||||
|
"family canon on a platform this project shares. Build from it."
|
||||||
|
)
|
||||||
|
lead = "is now a family-idea candidate" if created else "is already a candidate"
|
||||||
|
return (
|
||||||
|
f"This record repeats #{other.id} \"{other.title}\" from another project "
|
||||||
|
f"on a platform this one shares (similarity {score:.2f}). Two projects "
|
||||||
|
f"building the same idea is what family canon is for; #{other.id} {lead}. "
|
||||||
|
f"{_evaluate_line(other.id)}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def milestone_is_open(user_id: int, milestone_id: int) -> bool:
|
||||||
|
"""Whether a milestone is open right now — read before a status write, so
|
||||||
|
the trigger fires on the transition into done and not on every re-save of
|
||||||
|
a closed one. Fail-open to False: a hint must never break the write."""
|
||||||
|
from scribe.services import milestones as milestones_svc
|
||||||
|
|
||||||
|
try:
|
||||||
|
milestone = await milestones_svc.get_milestone(user_id, milestone_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("milestone %s status read failed", milestone_id, exc_info=True)
|
||||||
|
return False
|
||||||
|
return milestone is not None and milestone.status != "done"
|
||||||
|
|
||||||
|
|
||||||
|
async def milestone_trigger(user_id: int, milestone) -> str | None:
|
||||||
|
"""A milestone closing in a project that is on a platform: the moment to
|
||||||
|
ask whether what it built is something every project on that platform
|
||||||
|
will face. Records nothing — a milestone is not a record an idea can hang
|
||||||
|
on — so the hint asks for the note that would carry the idea."""
|
||||||
|
project_id = getattr(milestone, "project_id", None)
|
||||||
|
try:
|
||||||
|
if not project_id or not await access.can_read_project(user_id, project_id):
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
names = (await session.execute(
|
||||||
|
select(Platform.name)
|
||||||
|
.join(ProjectPlatform, ProjectPlatform.platform_id == Platform.id)
|
||||||
|
.where(ProjectPlatform.project_id == project_id,
|
||||||
|
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||||
|
Platform.deleted_at.is_(None))
|
||||||
|
.order_by(Platform.order_index.asc())
|
||||||
|
)).scalars().all()
|
||||||
|
except Exception:
|
||||||
|
# Fail-open: the milestone is already closed; the hint is decoration.
|
||||||
|
logger.warning("milestone trigger failed for %s", getattr(milestone, "id", None),
|
||||||
|
exc_info=True)
|
||||||
|
return None
|
||||||
|
if not names:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"This milestone closed on {', '.join(names)}. If it solved something every "
|
||||||
|
"project on those platforms will face — a problem the platform causes, or a "
|
||||||
|
"stance held across projects — the idea belongs in the family: write it up "
|
||||||
|
"as a note (when it applies, the traps, what proved it) and "
|
||||||
|
"propose_family_idea it, or promote_family_idea if it already meets the "
|
||||||
|
"three criteria. If what it built is this project's alone, nothing to do."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_family_hint(user_id: int, data: dict, note, *, created: bool) -> None:
|
||||||
|
"""Ride the trigger hints on a write's response — fail-open: a hint must
|
||||||
|
never break the write it rides on. Citation first (an explicit claim of
|
||||||
|
lineage), then, on a create, the repeat check."""
|
||||||
|
try:
|
||||||
|
hint = await citation_trigger(user_id, note)
|
||||||
|
if hint is None and created:
|
||||||
|
hint = await repeat_trigger(user_id, note)
|
||||||
|
if hint:
|
||||||
|
data["family_hint"] = hint
|
||||||
|
except Exception:
|
||||||
|
logger.warning("family trigger failed for note %s", getattr(note, "id", None), exc_info=True)
|
||||||
@@ -215,6 +215,30 @@ def _no_lesson_rule_links(request):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_family_triggers(request):
|
||||||
|
"""Stub family canon's write-time triggers (milestone 463 step 3).
|
||||||
|
|
||||||
|
create_note, create_task, create_snippet, the updates that change a body,
|
||||||
|
and update_milestone(status="done") now look for a pattern carried between
|
||||||
|
projects, and each look is a database read (the repeat check also embeds).
|
||||||
|
All are fail-open, so unstubbed they would cost a slow failed connect per
|
||||||
|
unit test rather than a failure. Patched at the trigger functions, beneath
|
||||||
|
`attach_family_hint`, so the attach itself — its order and its fail-open —
|
||||||
|
still runs. tests/test_family.py binds the real pure parts; the triggers
|
||||||
|
themselves are exercised against Postgres in
|
||||||
|
tests/test_integration_family_promotion.py, which this skips.
|
||||||
|
"""
|
||||||
|
if request.node.get_closest_marker("integration"):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
with patch("scribe.services.family.citation_trigger", AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.family.repeat_trigger", AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.family.milestone_trigger", AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.family.milestone_is_open", AsyncMock(return_value=False)):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_moment_delivery(request):
|
def _no_moment_delivery(request):
|
||||||
"""Stub the moment lookup the mapped MCP tools attach (milestone 458).
|
"""Stub the moment lookup the mapped MCP tools attach (milestone 458).
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""The promotion engine without a database (milestone 463 step 3).
|
||||||
|
|
||||||
|
The parts that decide — which citations claim lineage, which criteria veto —
|
||||||
|
are pure and pinned here, beside the doors' wiring: the triggers ride the
|
||||||
|
write tools, fail open, and the criteria the service enforces are the ones
|
||||||
|
the agent is told. The state machine against Postgres is in
|
||||||
|
tests/test_integration_family_promotion.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
||||||
|
from scribe.mcp.tools import family as family_tools
|
||||||
|
from scribe.mcp.tools.milestones import update_milestone
|
||||||
|
from scribe.services import family as family_svc
|
||||||
|
from scribe.services.family import CRITERIA_KEYS, lineage_citations, vetoes
|
||||||
|
from tests.helpers import fake_milestone
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||||
|
|
||||||
|
|
||||||
|
# --- which citations claim lineage ---------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", [
|
||||||
|
"Signed release lane, matching roundtable-android (task #1615).",
|
||||||
|
"Ported from #1615, with the keystore step moved first.",
|
||||||
|
"Same shape as #1615: the APK is baked into the image.",
|
||||||
|
"This mirrors #1615 for the desktop client.",
|
||||||
|
"Modelled on #1615.",
|
||||||
|
])
|
||||||
|
def test_a_citation_with_a_lineage_word_names_its_source(text):
|
||||||
|
assert lineage_citations(text) == [1615]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", [
|
||||||
|
"See #1615 for context.",
|
||||||
|
"#1615 is related.",
|
||||||
|
"Closes #1615.",
|
||||||
|
# The lineage word is in the PREVIOUS sentence — a different claim.
|
||||||
|
"This is matching the old flow. Unrelated: #1615.",
|
||||||
|
"Matching the old flow\nsee #1615",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
def test_a_citation_without_lineage_is_silent(text):
|
||||||
|
assert lineage_citations(text) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_lineage_citations_are_in_order_and_unique():
|
||||||
|
text = "Based on #20; ported from #10. Same pattern as #20."
|
||||||
|
assert lineage_citations(text) == [20, 10]
|
||||||
|
|
||||||
|
|
||||||
|
# --- each criterion vetoes on its own -----------------------------------------------
|
||||||
|
|
||||||
|
GOOD = dict(
|
||||||
|
applies_when="any app that installs its own updates",
|
||||||
|
platforms=["android-app"],
|
||||||
|
criteria={
|
||||||
|
"platform_terms": "stated as an Android distribution concern",
|
||||||
|
"platform_problem": "Play Protect and signature checks are the platform's",
|
||||||
|
"proven": "shipped in two apps",
|
||||||
|
},
|
||||||
|
evidence=["CI run 8274 green", "#4775"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_three_criteria_held_means_no_veto():
|
||||||
|
assert vetoes(**GOOD) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", CRITERIA_KEYS)
|
||||||
|
def test_each_criterion_left_unsupported_vetoes_on_its_own(key):
|
||||||
|
case = {**GOOD, "criteria": {**GOOD["criteria"], key: " "}}
|
||||||
|
assert vetoes(**case) == [key]
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_terms_needs_an_applicability_test_and_a_scope():
|
||||||
|
assert vetoes(**{**GOOD, "applies_when": ""}) == ["platform_terms"]
|
||||||
|
assert vetoes(**{**GOOD, "platforms": []}) == ["platform_terms"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_proven_needs_named_evidence():
|
||||||
|
assert vetoes(**{**GOOD, "evidence": []}) == ["proven"]
|
||||||
|
assert vetoes(**{**GOOD, "evidence": [" "]}) == ["proven"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_criteria_the_agent_is_told_are_the_ones_enforced():
|
||||||
|
"""Rule 119: the criteria are product text. The promote tool's docstring
|
||||||
|
must name every criterion the service checks, by its parameter name."""
|
||||||
|
doc = family_tools.promote_family_idea.__doc__
|
||||||
|
for key in CRITERIA_KEYS:
|
||||||
|
assert key in doc, f"promote_family_idea's docstring never names {key}"
|
||||||
|
assert len(family_svc.CRITERIA) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- the doors ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_every_family_tool_is_classified():
|
||||||
|
reads = {"list_family_ideas", "get_family_idea", "list_family_decisions"}
|
||||||
|
writes = {"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||||
|
"undo_family_decision"}
|
||||||
|
assert reads <= _READ_ONLY_TOOLS
|
||||||
|
assert writes <= _WRITE_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def _note(**kw):
|
||||||
|
base = dict(id=7, project_id=2, title="t", body="b", is_task=False)
|
||||||
|
return SimpleNamespace(**{**base, **kw})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_citation_hint_wins_and_repeat_is_not_asked():
|
||||||
|
data: dict = {}
|
||||||
|
repeat = AsyncMock(return_value="repeat")
|
||||||
|
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value="cited")), \
|
||||||
|
patch.object(family_svc, "repeat_trigger", repeat):
|
||||||
|
await family_svc.attach_family_hint(1, data, _note(), created=True)
|
||||||
|
assert data == {"family_hint": "cited"}
|
||||||
|
repeat.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_repeat_check_runs_only_on_a_create():
|
||||||
|
repeat = AsyncMock(return_value="repeat")
|
||||||
|
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value=None)), \
|
||||||
|
patch.object(family_svc, "repeat_trigger", repeat):
|
||||||
|
edited: dict = {}
|
||||||
|
await family_svc.attach_family_hint(1, edited, _note(), created=False)
|
||||||
|
created: dict = {}
|
||||||
|
await family_svc.attach_family_hint(1, created, _note(), created=True)
|
||||||
|
assert edited == {}
|
||||||
|
assert created == {"family_hint": "repeat"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_failing_trigger_never_breaks_the_write():
|
||||||
|
data: dict = {"id": 7}
|
||||||
|
with patch.object(family_svc, "citation_trigger", AsyncMock(side_effect=RuntimeError("db down"))):
|
||||||
|
await family_svc.attach_family_hint(1, data, _note(), created=True)
|
||||||
|
assert data == {"id": 7}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closing_a_milestone_on_a_platform_carries_the_hint():
|
||||||
|
closed = fake_milestone(id=5, project_id=3, status="done")
|
||||||
|
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
|
||||||
|
AsyncMock(return_value=closed)), \
|
||||||
|
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=True)), \
|
||||||
|
patch.object(family_svc, "milestone_trigger", AsyncMock(return_value="evaluate")):
|
||||||
|
out = await update_milestone(project_id=3, milestone_id=5, status="done")
|
||||||
|
assert out["family_hint"] == "evaluate"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_re_saving_a_closed_milestone_asks_nothing():
|
||||||
|
closed = fake_milestone(id=5, project_id=3, status="done")
|
||||||
|
trigger = AsyncMock(return_value="evaluate")
|
||||||
|
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
|
||||||
|
AsyncMock(return_value=closed)), \
|
||||||
|
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=False)), \
|
||||||
|
patch.object(family_svc, "milestone_trigger", trigger):
|
||||||
|
out = await update_milestone(project_id=3, milestone_id=5, status="done")
|
||||||
|
assert "family_hint" not in out
|
||||||
|
trigger.assert_not_awaited()
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""The promotion engine against real Postgres (milestone 463 step 3).
|
||||||
|
|
||||||
|
What the unit lane can't show: a promotion opens the right ledger rows and
|
||||||
|
no others, a veto changes nothing but the log, an undo puts back exactly the
|
||||||
|
recorded prior state, and each trigger fires on its fixture and stays silent
|
||||||
|
on the near-miss beside it.
|
||||||
|
|
||||||
|
Precedent search and the repeat trigger both rank by meaning, and the lane
|
||||||
|
has no embedding model. The search is stubbed to "nothing similar" for every
|
||||||
|
test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
|
||||||
|
the trigger does WITH a hit. The ranking itself is the shared semantic
|
||||||
|
search, tested where that lives.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.family import (
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform,
|
||||||
|
)
|
||||||
|
from scribe.models.milestone import Milestone
|
||||||
|
from scribe.models.note import Note
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.user import User
|
||||||
|
from scribe.services import family as family_svc
|
||||||
|
from tests.helpers import ensure_user
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||||
|
|
||||||
|
OWNER = "family_promotion_owner"
|
||||||
|
OUTSIDER = "family_promotion_outsider"
|
||||||
|
|
||||||
|
CRITERIA = {
|
||||||
|
"platform_terms": "stated for any Android app that distributes its own APK",
|
||||||
|
"platform_problem": "signature continuity is the platform's rule, not one app's",
|
||||||
|
"proven": "shipped and updated in place on a device",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge(username: str) -> None:
|
||||||
|
"""SETUP ONLY, as the backup round-trip siblings do: a database call
|
||||||
|
after a `yield` in an autouse fixture orphans a pooled connection."""
|
||||||
|
async with async_session() as s:
|
||||||
|
for user in (await s.execute(select(User).where(User.username == username))).scalars():
|
||||||
|
for note in (await s.execute(select(Note).where(Note.user_id == user.id))).scalars():
|
||||||
|
await s.delete(note)
|
||||||
|
for project in (await s.execute(select(Project).where(Project.user_id == user.id))).scalars():
|
||||||
|
await s.delete(project)
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def _clean():
|
||||||
|
await _purge(OWNER)
|
||||||
|
await _purge(OUTSIDER)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_meaning():
|
||||||
|
with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def _platform_id(slug: str) -> int:
|
||||||
|
async with async_session() as s:
|
||||||
|
return await s.scalar(select(Platform.id).where(
|
||||||
|
Platform.slug == slug, Platform.deleted_at.is_(None)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def family():
|
||||||
|
"""Three projects: two Android apps and a Go service, the pattern note
|
||||||
|
in the first, and an outsider's Android app the owner cannot write."""
|
||||||
|
android, go = await _platform_id("android-app"), await _platform_id("go")
|
||||||
|
async with async_session() as s:
|
||||||
|
owner = await ensure_user(s, OWNER)
|
||||||
|
outsider = await ensure_user(s, OUTSIDER)
|
||||||
|
a = Project(user_id=owner.id, title="android one")
|
||||||
|
b = Project(user_id=owner.id, title="android two")
|
||||||
|
c = Project(user_id=owner.id, title="go service")
|
||||||
|
theirs = Project(user_id=outsider.id, title="someone else's android app")
|
||||||
|
s.add_all([a, b, c, theirs])
|
||||||
|
await s.flush()
|
||||||
|
s.add_all([
|
||||||
|
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
|
||||||
|
ProjectPlatform(project_id=b.id, platform_id=android, state="detected"),
|
||||||
|
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
||||||
|
ProjectPlatform(project_id=theirs.id, platform_id=android, state="declared"),
|
||||||
|
])
|
||||||
|
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
|
||||||
|
body="One keystore, two channels, in-place update.")
|
||||||
|
s.add(pattern)
|
||||||
|
await s.commit()
|
||||||
|
return {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id,
|
||||||
|
"c": c.id, "theirs": theirs.id, "note": pattern.id}
|
||||||
|
|
||||||
|
|
||||||
|
async def _promote(f, **overrides):
|
||||||
|
kw = dict(
|
||||||
|
applies_when="any Android app that ships its own APK",
|
||||||
|
platforms=["android-app"], criteria=CRITERIA,
|
||||||
|
evidence=["CI green on the release lane", "verified on a device"],
|
||||||
|
reason="built twice, proven, stated for the platform",
|
||||||
|
)
|
||||||
|
kw.update(overrides)
|
||||||
|
return await family_svc.promote(f["owner"], f["note"], **kw)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ledger(note_id: int) -> dict[int, str]:
|
||||||
|
async with async_session() as s:
|
||||||
|
rows = (await s.execute(
|
||||||
|
select(FamilyAdoption.project_id, FamilyAdoption.status)
|
||||||
|
.where(FamilyAdoption.idea_id == note_id)
|
||||||
|
)).all()
|
||||||
|
return dict(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def _idea(note_id: int) -> FamilyIdea:
|
||||||
|
async with async_session() as s:
|
||||||
|
return await s.get(FamilyIdea, note_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- promotion ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_promotion_opens_a_row_for_every_member_project_the_promoter_can_write(family):
|
||||||
|
out = await _promote(family)
|
||||||
|
assert out["promoted"] is True
|
||||||
|
# Declared and detected both count as membership; the Go service is not
|
||||||
|
# on the platform; the outsider's app is not the promoter's to write.
|
||||||
|
assert await _ledger(family["note"]) == {family["a"]: "unassessed",
|
||||||
|
family["b"]: "unassessed"}
|
||||||
|
idea = await _idea(family["note"])
|
||||||
|
assert idea.status == "canon" and idea.canon_version == 1
|
||||||
|
decision = out["decision"]
|
||||||
|
assert decision["action"] == "promote"
|
||||||
|
assert decision["after"]["platforms"] == ["android-app"]
|
||||||
|
assert decision["evidence"]["criteria"]["proven"] == CRITERIA["proven"]
|
||||||
|
assert decision["evidence"]["ledger_rows_opened"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", list(family_svc.CRITERIA_KEYS))
|
||||||
|
async def test_each_criterion_vetoes_on_its_own_and_the_veto_is_logged(family, key):
|
||||||
|
out = await _promote(family, criteria={**CRITERIA, key: ""})
|
||||||
|
assert out["promoted"] is False and out["vetoed_by"] == [key]
|
||||||
|
assert (await _idea(family["note"])).status == "candidate"
|
||||||
|
assert await _ledger(family["note"]) == {}
|
||||||
|
assert out["decision"]["action"] == "propose"
|
||||||
|
assert out["decision"]["evidence"]["vetoed_by"] == [key]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_unknown_platform_is_refused_before_anything_is_written(family):
|
||||||
|
with pytest.raises(ValueError, match="unknown platform"):
|
||||||
|
await _promote(family, platforms=["android-app", "no-such-platform"])
|
||||||
|
assert await _idea(family["note"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_someone_who_cannot_write_the_note_cannot_promote_it(family):
|
||||||
|
with pytest.raises(ValueError, match="no write access"):
|
||||||
|
await family_svc.promote(
|
||||||
|
family["outsider"], family["note"], applies_when="x", platforms=["android-app"],
|
||||||
|
criteria=CRITERIA, evidence=["e"], reason="r",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- undo and retirement ------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows(family):
|
||||||
|
out = await _promote(family)
|
||||||
|
async with async_session() as s:
|
||||||
|
row = (await s.execute(select(FamilyAdoption).where(
|
||||||
|
FamilyAdoption.idea_id == family["note"],
|
||||||
|
FamilyAdoption.project_id == family["a"]))).scalars().one()
|
||||||
|
row.status, row.canon_version, row.decided_via = "adopted", 1, "agent"
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
await family_svc.undo(family["owner"], out["decision"]["id"], reason="promoted too early")
|
||||||
|
idea = await _idea(family["note"])
|
||||||
|
# Promoted directly, with no proposal before it: the prior state was "not
|
||||||
|
# an idea", which an undo records as retired rather than deleting the
|
||||||
|
# idea and its log with it.
|
||||||
|
assert idea.status == "retired" and idea.applies_when is None
|
||||||
|
# The unjudged row goes; the judged one stays as history.
|
||||||
|
assert await _ledger(family["note"]) == {family["a"]: "adopted"}
|
||||||
|
|
||||||
|
again = await _promote(family)
|
||||||
|
# Re-promotion moves PAST every version this idea has held, so the kept
|
||||||
|
# answer reads as needing a recheck rather than as still agreeing.
|
||||||
|
assert again["idea"]["canon_version"] == 2
|
||||||
|
assert await _ledger(family["note"]) == {family["a"]: "adopted", family["b"]: "unassessed"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_retiring_closes_unjudged_rows_and_undoing_it_reopens_them(family):
|
||||||
|
await _promote(family)
|
||||||
|
out = await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||||
|
assert (await _idea(family["note"])).status == "retired"
|
||||||
|
assert await _ledger(family["note"]) == {}
|
||||||
|
await family_svc.undo(family["owner"], out["decision"]["id"], reason="not superseded after all")
|
||||||
|
assert (await _idea(family["note"])).status == "canon"
|
||||||
|
assert set((await _ledger(family["note"])).values()) == {"unassessed"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_the_latest_idea_decision_can_be_undone(family):
|
||||||
|
first = await _promote(family)
|
||||||
|
await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||||
|
with pytest.raises(ValueError, match="came after it"):
|
||||||
|
await family_svc.undo(family["owner"], first["decision"]["id"], reason="r")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_undo_names_what_it_reversed_as_its_precedent(family):
|
||||||
|
out = await _promote(family)
|
||||||
|
undo = await family_svc.undo(family["owner"], out["decision"]["id"], reason="r")
|
||||||
|
assert undo["decision"]["action"] == "undo"
|
||||||
|
assert undo["decision"]["precedent_ids"] == [out["decision"]["id"]]
|
||||||
|
async with async_session() as s:
|
||||||
|
actions = (await s.execute(select(FamilyDecision.action).where(
|
||||||
|
FamilyDecision.idea_id == family["note"]).order_by(FamilyDecision.id))).scalars().all()
|
||||||
|
assert actions == ["promote", "undo"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- triggers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _note_in(project_id: int, owner_id: int, body: str) -> Note:
|
||||||
|
async with async_session() as s:
|
||||||
|
note = Note(user_id=owner_id, project_id=project_id, title="the second build", body=body)
|
||||||
|
s.add(note)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(note)
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_cross_project_lineage_citation_opens_an_evaluation(family):
|
||||||
|
citing = await _note_in(family["b"], family["owner"],
|
||||||
|
f"Release lane, matching the first app (#{family['note']}).")
|
||||||
|
hint = await family_svc.citation_trigger(family["owner"], citing)
|
||||||
|
assert hint and f"#{family['note']}" in hint
|
||||||
|
idea = await _idea(family["note"])
|
||||||
|
assert idea is not None and idea.status == "candidate"
|
||||||
|
async with async_session() as s:
|
||||||
|
d = (await s.execute(select(FamilyDecision).where(
|
||||||
|
FamilyDecision.idea_id == family["note"]))).scalars().one()
|
||||||
|
assert d.decided_via == "system" and d.evidence["trigger"] == "citation"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_citation_without_lineage_or_within_one_project_is_silent(family):
|
||||||
|
pointer = await _note_in(family["b"], family["owner"], f"See #{family['note']} for context.")
|
||||||
|
same_project = await _note_in(family["a"], family["owner"], f"Matching #{family['note']}.")
|
||||||
|
assert await family_svc.citation_trigger(family["owner"], pointer) is None
|
||||||
|
assert await family_svc.citation_trigger(family["owner"], same_project) is None
|
||||||
|
assert await _idea(family["note"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family):
|
||||||
|
"""The meaning match is stubbed — the lane has no model — so this pins
|
||||||
|
what the trigger does with a hit: only a hit in ANOTHER project that
|
||||||
|
shares a platform counts."""
|
||||||
|
repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.")
|
||||||
|
async with async_session() as s:
|
||||||
|
original = await s.get(Note, family["note"])
|
||||||
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||||
|
AsyncMock(return_value=[(0.86, original)])):
|
||||||
|
hint = await family_svc.repeat_trigger(family["owner"], repeat)
|
||||||
|
assert hint and f"#{family['note']}" in hint
|
||||||
|
assert (await _idea(family["note"])).status == "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_repeat_with_no_shared_platform_is_silent(family):
|
||||||
|
unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.")
|
||||||
|
async with async_session() as s:
|
||||||
|
original = await s.get(Note, family["note"])
|
||||||
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||||
|
AsyncMock(return_value=[(0.86, original)])):
|
||||||
|
assert await family_svc.repeat_trigger(family["owner"], unrelated) is None
|
||||||
|
assert await _idea(family["note"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
|
||||||
|
async with async_session() as s:
|
||||||
|
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
|
||||||
|
bare = Project(user_id=family["owner"], title="no platforms")
|
||||||
|
s.add_all([on_platform, bare])
|
||||||
|
await s.flush()
|
||||||
|
off_platform = Milestone(user_id=family["owner"], project_id=bare.id, title="m2")
|
||||||
|
s.add(off_platform)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(on_platform)
|
||||||
|
await s.refresh(off_platform)
|
||||||
|
hint = await family_svc.milestone_trigger(family["owner"], on_platform)
|
||||||
|
assert hint and "Android app" in hint
|
||||||
|
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
|
||||||
|
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Structural tests for the family blueprint (milestone 463 step 3) — every
|
||||||
|
endpoint is routed, and every write hands the service its caller, where the
|
||||||
|
note's write gate lives. What the writes do is in
|
||||||
|
tests/test_integration_family_promotion.py."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
|
def test_family_blueprint_routes_every_endpoint():
|
||||||
|
from scribe.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
assert "family" in app.blueprints
|
||||||
|
rules = {(r.rule, m) for r in app.url_map.iter_rules() for m in r.methods}
|
||||||
|
for rule, method in (
|
||||||
|
("/api/family/ideas", "GET"),
|
||||||
|
("/api/family/ideas/<int:note_id>", "GET"),
|
||||||
|
("/api/family/ideas/<int:note_id>/propose", "POST"),
|
||||||
|
("/api/family/ideas/<int:note_id>/promote", "POST"),
|
||||||
|
("/api/family/ideas/<int:note_id>/retire", "POST"),
|
||||||
|
("/api/family/decisions", "GET"),
|
||||||
|
("/api/family/decisions/<int:decision_id>/undo", "POST"),
|
||||||
|
):
|
||||||
|
assert (rule, method) in rules, f"{method} {rule} is not routed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_engine_write_takes_the_caller_and_who_decided():
|
||||||
|
from scribe.services import family as svc
|
||||||
|
for name in ("propose", "promote", "retire", "undo"):
|
||||||
|
params = inspect.signature(getattr(svc, name)).parameters
|
||||||
|
assert "user_id" in params and "decided_via" in params, name
|
||||||
Reference in New Issue
Block a user