feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
- services/family_adoption.py: assess one project against one canon idea by the four outcomes in order (exempt, variant, adopted, owed). Every outcome needs a reason and adopted needs evidence. The engine records the precedents itself: this idea's answers elsewhere, and this project's answers to the nearest ideas. The same answer given twice records nothing. - owed files a task in the OWING project, tagged to the System matching the idea's canonical area, naming the gap and the reference for that project's language. The task follows the answer: adopted closes it, exempt or variant cancels it, owed again reopens it. Each move is logged on the task. - the conflict order is enforced: every ground above the deciding one must say why it did not decide. The losing side is folded into the idea's note as a trap, an alternative or a condition branch, the version moves, and both rows are answered against the revision. - recheck is derived (row version != idea version). family.revise moves the version when substance changes. undo covers a project's latest answer too. - set_family_references names the reference implementations. - MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea, set_family_references. Web: GET /api/family/matrix. - UI: an adoption matrix on /family (platform filter, cell detail with reason, recheck and owed-task link) and the same matrix narrowed to one project on its Family tab. The decision log now reads project-level decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,15 +40,29 @@ export interface IdeaSnapshot {
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
export type AdoptionStatus = "unassessed" | "adopted" | "variant" | "exempt" | "owed";
|
||||
|
||||
/** One project's answer as a decision records it. */
|
||||
export interface AdoptionSnapshot {
|
||||
status: AdoptionStatus;
|
||||
reason: string;
|
||||
canon_version: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decision about the idea itself (project_id null) snapshots the idea; a
|
||||
* project's assessment (project_id set) snapshots that project's answer.
|
||||
*/
|
||||
export interface FamilyDecision {
|
||||
id: number;
|
||||
idea_id: number;
|
||||
idea_title?: string;
|
||||
project_id: number | null;
|
||||
project_title?: string | null;
|
||||
action: DecisionAction;
|
||||
reason: string;
|
||||
before: IdeaSnapshot | null;
|
||||
after: IdeaSnapshot | null;
|
||||
before: IdeaSnapshot | AdoptionSnapshot | null;
|
||||
after: IdeaSnapshot | AdoptionSnapshot | null;
|
||||
evidence: Record<string, unknown>;
|
||||
precedent_ids: number[];
|
||||
decided_via: "agent" | "operator" | "system";
|
||||
@@ -57,7 +71,17 @@ export interface FamilyDecision {
|
||||
undoable?: boolean;
|
||||
}
|
||||
|
||||
export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> {
|
||||
/** One ground of the conflict order, in order. */
|
||||
export interface ConflictGround {
|
||||
key: string;
|
||||
title: string;
|
||||
test: string;
|
||||
fold_as: "alternative" | "trap" | "condition";
|
||||
}
|
||||
|
||||
export async function listFamilyIdeas(
|
||||
status = "",
|
||||
): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[]; conflict_order?: ConflictGround[] }> {
|
||||
const q = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiGet(`/api/family/ideas${q}`);
|
||||
}
|
||||
@@ -76,3 +100,57 @@ export async function undoFamilyDecision(id: number, reason: string) {
|
||||
export async function retireFamilyIdea(noteId: number, reason: string) {
|
||||
return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
|
||||
}
|
||||
|
||||
export interface Outcome {
|
||||
key: AdoptionStatus;
|
||||
title: string;
|
||||
test: string;
|
||||
}
|
||||
|
||||
/** One project's answer to one canon idea — a cell of the matrix. */
|
||||
export interface AdoptionCell {
|
||||
project_id: number;
|
||||
project_title: string;
|
||||
idea_id: number;
|
||||
idea_title: string;
|
||||
idea_version: number;
|
||||
/** The project is on one of the idea's platforms. */
|
||||
in_scope: boolean;
|
||||
/** A ledger row exists. False: the idea reaches the project, nobody asked yet. */
|
||||
reached: boolean;
|
||||
status: AdoptionStatus;
|
||||
reason: string;
|
||||
canon_version: number | null;
|
||||
assessed_at: string | null;
|
||||
decided_via: "agent" | "operator" | "system" | null;
|
||||
/** Answered against a canon version that is not the current one. */
|
||||
needs_recheck: boolean;
|
||||
owed_task: { id: number; title: string; status: string } | null;
|
||||
}
|
||||
|
||||
export interface MatrixIdea {
|
||||
note_id: number;
|
||||
title: string;
|
||||
note_type: string;
|
||||
is_task: boolean;
|
||||
canon_version: number;
|
||||
applies_when: string;
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
export interface AdoptionMatrix {
|
||||
ideas: MatrixIdea[];
|
||||
projects: { id: number; title: string; platforms: string[] }[];
|
||||
cells: AdoptionCell[];
|
||||
outcomes: Outcome[];
|
||||
}
|
||||
|
||||
export async function fetchAdoptionMatrix(
|
||||
opts: { platform?: string; projectId?: number } = {},
|
||||
): Promise<AdoptionMatrix> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.platform) q.set("platform", opts.platform);
|
||||
if (opts.projectId) q.set("project_id", String(opts.projectId));
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/family/matrix${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The adoption matrix (milestone 463 step 4): every project × every canon
|
||||
* family idea that reaches it, and the project's answer.
|
||||
*
|
||||
* Nobody approves an answer — the agent assesses each project against the
|
||||
* four outcomes and records why. This is where a person sees that happening:
|
||||
* which projects adopted, which depart and for what fact, which owe the work
|
||||
* (with the task filed in that project), and which answers were given
|
||||
* against an older version of the idea and need a recheck. Disagreeing is an
|
||||
* undo in the decision log below.
|
||||
*
|
||||
* `projectId` narrows it to one project's answers — the same component, so a
|
||||
* project's Family tab and this page cannot describe a cell differently.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
fetchAdoptionMatrix,
|
||||
type AdoptionCell, type AdoptionMatrix, type AdoptionStatus,
|
||||
} from "@/api/family";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
import { recordHref } from "@/utils/recordHref";
|
||||
|
||||
const props = defineProps<{ projectId?: number }>();
|
||||
|
||||
const platformsStore = usePlatformsStore();
|
||||
const matrix = ref<AdoptionMatrix | null>(null);
|
||||
const platform = ref("");
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const selectedKey = ref<string | null>(null);
|
||||
|
||||
const LABELS: Record<AdoptionStatus, string> = {
|
||||
unassessed: "Not assessed",
|
||||
adopted: "Adopted",
|
||||
variant: "Variant",
|
||||
exempt: "Doesn't apply",
|
||||
owed: "Owed",
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const [, data] = await Promise.all([
|
||||
platformsStore.fetchCatalog(),
|
||||
fetchAdoptionMatrix({ platform: platform.value, projectId: props.projectId }),
|
||||
]);
|
||||
matrix.value = data;
|
||||
if (selectedKey.value && !data.cells.some((c) => key(c) === selectedKey.value)) {
|
||||
selectedKey.value = null;
|
||||
}
|
||||
} catch {
|
||||
// A matrix that failed to load must not read as "nobody owes anything".
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function key(c: { project_id: number; idea_id: number }): string {
|
||||
return `${c.project_id}:${c.idea_id}`;
|
||||
}
|
||||
|
||||
const cellByKey = computed(() => {
|
||||
const out: Record<string, AdoptionCell> = {};
|
||||
for (const c of matrix.value?.cells ?? []) out[key(c)] = c;
|
||||
return out;
|
||||
});
|
||||
|
||||
const selected = computed(() =>
|
||||
selectedKey.value ? cellByKey.value[selectedKey.value] ?? null : null,
|
||||
);
|
||||
|
||||
const outcomeTest = computed(() => {
|
||||
const out: Partial<Record<AdoptionStatus, string>> = {};
|
||||
for (const o of matrix.value?.outcomes ?? []) out[o.key] = o.test;
|
||||
return out;
|
||||
});
|
||||
|
||||
/** The answers that want attention: owed, or given against an older canon. */
|
||||
const summary = computed(() => {
|
||||
const cells = matrix.value?.cells ?? [];
|
||||
return {
|
||||
owed: cells.filter((c) => c.status === "owed").length,
|
||||
recheck: cells.filter((c) => c.needs_recheck).length,
|
||||
unassessed: cells.filter((c) => c.status === "unassessed").length,
|
||||
};
|
||||
});
|
||||
|
||||
function toggle(c: AdoptionCell) {
|
||||
selectedKey.value = selectedKey.value === key(c) ? null : key(c);
|
||||
}
|
||||
|
||||
function cellTitle(c: AdoptionCell): string {
|
||||
const parts = [LABELS[c.status]];
|
||||
if (c.needs_recheck) parts.push(`answered against v${c.canon_version}, now v${c.idea_version}`);
|
||||
if (c.reason) parts.push(c.reason);
|
||||
return parts.join(" — ");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(platform, load);
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fam-matrix-block">
|
||||
<div class="fam-matrix-head">
|
||||
<p class="fam-muted">
|
||||
<template v-if="projectId">This project's answer to each family idea that reaches it.</template>
|
||||
<template v-else>Each project's answer to each canon idea that reaches it.</template>
|
||||
The agent answers by the four outcomes below and records why; “recheck” means
|
||||
the idea changed after it was answered.
|
||||
</p>
|
||||
<label v-if="platformsStore.catalog.length" class="fam-select-label">
|
||||
Platform
|
||||
<select v-model="platform" class="fam-select">
|
||||
<option value="">All</option>
|
||||
<option v-for="p in platformsStore.catalog" :key="p.slug" :value="p.slug">{{ p.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="loading && !matrix" class="fam-muted">Loading…</p>
|
||||
|
||||
<div v-else-if="failed" class="fam-note">
|
||||
<strong>The adoption matrix couldn't load.</strong>
|
||||
<p>Nothing is known either way — this is a failure, not an empty ledger.</p>
|
||||
<button class="btn-secondary btn-sm" @click="load">Try again</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="matrix">
|
||||
<p v-if="!matrix.ideas.length" class="fam-muted">
|
||||
<template v-if="platform">No canon idea is for this platform yet.</template>
|
||||
<template v-else>No idea is canon yet, so nothing has reached a project.</template>
|
||||
</p>
|
||||
<p v-else-if="!matrix.projects.length" class="fam-muted">
|
||||
<template v-if="projectId">
|
||||
No canon idea reaches this project — it is on none of their platforms.
|
||||
</template>
|
||||
<template v-else>
|
||||
No project is on a platform any canon idea is for. A project joins a platform
|
||||
on its Family tab, or by detection from a bound repo.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="fam-summary">
|
||||
{{ summary.owed }} owed · {{ summary.recheck }} to recheck ·
|
||||
{{ summary.unassessed }} not assessed
|
||||
</p>
|
||||
<div class="fam-matrix-wrap" role="region" aria-label="Adoption matrix" tabindex="0">
|
||||
<table class="fam-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="fam-corner">Project</th>
|
||||
<th v-for="idea in matrix.ideas" :key="idea.note_id" scope="col">
|
||||
<router-link
|
||||
:to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })"
|
||||
:title="idea.applies_when ? `Applies when: ${idea.applies_when}` : undefined"
|
||||
>{{ idea.title }}</router-link>
|
||||
<span class="fam-version">v{{ idea.canon_version }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in matrix.projects" :key="p.id">
|
||||
<th scope="row">
|
||||
<router-link :to="`/projects/${p.id}`">{{ p.title }}</router-link>
|
||||
</th>
|
||||
<td v-for="idea in matrix.ideas" :key="idea.note_id">
|
||||
<button
|
||||
v-if="cellByKey[`${p.id}:${idea.note_id}`]"
|
||||
type="button"
|
||||
:class="['fam-cell', `fam-cell-${cellByKey[`${p.id}:${idea.note_id}`].status}`,
|
||||
{ 'fam-cell-on': selectedKey === `${p.id}:${idea.note_id}` }]"
|
||||
:aria-pressed="selectedKey === `${p.id}:${idea.note_id}`"
|
||||
:title="cellTitle(cellByKey[`${p.id}:${idea.note_id}`])"
|
||||
@click="toggle(cellByKey[`${p.id}:${idea.note_id}`])"
|
||||
>
|
||||
{{ LABELS[cellByKey[`${p.id}:${idea.note_id}`].status] }}
|
||||
<span v-if="cellByKey[`${p.id}:${idea.note_id}`].needs_recheck" class="fam-recheck">recheck</span>
|
||||
</button>
|
||||
<span v-else class="fam-cell-none" title="Not on this idea's platforms">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="fam-detail" aria-live="polite">
|
||||
<div class="fam-detail-head">
|
||||
<strong>{{ selected.project_title }} · {{ selected.idea_title }}</strong>
|
||||
<span :class="['fam-cell', `fam-cell-${selected.status}`]">{{ LABELS[selected.status] }}</span>
|
||||
<button type="button" class="btn-ghost btn-sm" @click="selectedKey = null">Close</button>
|
||||
</div>
|
||||
<p v-if="selected.reason" class="fam-detail-reason">{{ selected.reason }}</p>
|
||||
<p v-else-if="!selected.reached" class="fam-muted">
|
||||
The idea reaches this project, but nobody has been asked yet — whoever promoted
|
||||
it could not write here. The project's own agent answers it on its next pass.
|
||||
</p>
|
||||
<p v-else-if="selected.status === 'unassessed'" class="fam-muted">
|
||||
Waiting for the agent to assess it against the four outcomes.
|
||||
</p>
|
||||
<p v-if="selected.needs_recheck" class="fam-detail-recheck">
|
||||
Answered against v{{ selected.canon_version }}; the idea is now
|
||||
v{{ selected.idea_version }}, so this answer needs a recheck.
|
||||
</p>
|
||||
<p v-if="!selected.in_scope" class="fam-muted">
|
||||
This project is no longer on the idea's platforms; the answer is kept as history.
|
||||
</p>
|
||||
<p v-if="selected.owed_task" class="fam-detail-task">
|
||||
Owed task:
|
||||
<router-link :to="recordHref({ id: selected.owed_task.id, is_task: true })">
|
||||
{{ selected.owed_task.title }}
|
||||
</router-link>
|
||||
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
|
||||
</p>
|
||||
<p v-if="selected.assessed_at" class="fam-muted">
|
||||
Answered {{ fmtStamp(selected.assessed_at) }}
|
||||
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
|
||||
<template v-if="selected.canon_version"> against v{{ selected.canon_version }}</template>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<details class="fam-outcomes">
|
||||
<summary>The four outcomes</summary>
|
||||
<dl>
|
||||
<template v-for="o in matrix.outcomes" :key="o.key">
|
||||
<dt>{{ LABELS[o.key] }}</dt>
|
||||
<dd>{{ outcomeTest[o.key] }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fam-matrix-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.75rem; }
|
||||
.fam-matrix-head .fam-muted { flex: 1; min-width: 16rem; 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-select-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.fam-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fam-summary { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.fam-matrix-wrap {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.fam-matrix-wrap:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
button.fam-cell:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.fam-matrix { border-collapse: collapse; font-size: 0.85rem; min-width: 100%; }
|
||||
.fam-matrix th, .fam-matrix td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.fam-matrix thead th { font-weight: 500; min-width: 9rem; max-width: 14rem; }
|
||||
.fam-matrix tbody th { font-weight: 500; white-space: nowrap; }
|
||||
.fam-matrix tbody tr:last-child th, .fam-matrix tbody tr:last-child td { border-bottom: none; }
|
||||
.fam-corner { color: var(--fs-text-tertiary); }
|
||||
.fam-version { display: block; color: var(--fs-text-tertiary); font-size: 0.75rem; font-weight: 400; }
|
||||
.fam-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: transparent;
|
||||
color: var(--fs-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
button.fam-cell { cursor: pointer; }
|
||||
button.fam-cell:hover { background: var(--fs-surface-hover); }
|
||||
.fam-cell-on { outline: 2px solid var(--fs-text-secondary); outline-offset: 1px; }
|
||||
.fam-cell-adopted { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
|
||||
.fam-cell-owed { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
|
||||
.fam-cell-variant { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
|
||||
.fam-cell-exempt { color: var(--fs-status-cancelled-fg); }
|
||||
.fam-cell-unassessed { color: var(--fs-text-tertiary); border-style: dashed; }
|
||||
.fam-recheck {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.fam-cell-none { color: var(--fs-text-tertiary); }
|
||||
.fam-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.fam-detail-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; }
|
||||
.fam-detail-head .btn-ghost { margin-left: auto; }
|
||||
.fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; }
|
||||
.fam-detail-reason { color: var(--fs-text-primary); }
|
||||
.fam-detail-recheck { color: var(--fs-text-secondary); }
|
||||
.fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); }
|
||||
.fam-outcomes dl { margin: 0.5rem 0 0; }
|
||||
.fam-outcomes dt { font-weight: 500; color: var(--fs-text-primary); }
|
||||
.fam-outcomes dd { margin: 0 0 0.4rem 1rem; }
|
||||
</style>
|
||||
@@ -16,6 +16,10 @@
|
||||
*
|
||||
* Each change is saved as it is made: one platform, one request, applied
|
||||
* whole or refused whole.
|
||||
*
|
||||
* Below the platforms: this project's answer to each canon idea that reaches
|
||||
* it — the adoption matrix narrowed to one row, so it reads exactly as the
|
||||
* family page does.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
type PlatformState, type ProjectPlatform, type SettablePlatformState,
|
||||
} from "@/api/platforms";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
||||
|
||||
const props = defineProps<{ projectId: number; canWrite: boolean }>();
|
||||
|
||||
@@ -150,6 +155,11 @@ watch(() => props.projectId, load);
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<section class="pft-answers" aria-labelledby="pft-answers-title">
|
||||
<h3 id="pft-answers-title" class="pft-title">Family ideas</h3>
|
||||
<FamilyAdoptionMatrix :key="projectId" :project-id="projectId" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -195,6 +205,7 @@ watch(() => props.projectId, load);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pft-answers { margin-top: 1.75rem; }
|
||||
@media (max-width: 640px) {
|
||||
.pft-row { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
@@ -4,17 +4,21 @@
|
||||
* 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.
|
||||
* shown at the top, and records why — and nobody approves a project's answer
|
||||
* either: the adoption matrix shows each project's answer to each canon idea
|
||||
* as the agent gave it. 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 (or on one project's answer). Both ask for a reason,
|
||||
* because the reason is what the next similar decision 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,
|
||||
type AdoptionSnapshot, type ConflictGround, type Criterion, type FamilyDecision,
|
||||
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
||||
} from "@/api/family";
|
||||
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
import { recordHref } from "@/utils/recordHref";
|
||||
@@ -23,6 +27,8 @@ const PAGE = 50;
|
||||
const toast = useToastStore();
|
||||
|
||||
const criteria = ref<Criterion[]>([]);
|
||||
const conflictOrder = ref<ConflictGround[]>([]);
|
||||
const matrixRef = ref<InstanceType<typeof FamilyAdoptionMatrix> | null>(null);
|
||||
const ideas = ref<FamilyIdea[]>([]);
|
||||
const decisions = ref<FamilyDecision[]>([]);
|
||||
const statusFilter = ref<"" | IdeaStatus>("");
|
||||
@@ -55,6 +61,7 @@ async function load() {
|
||||
]);
|
||||
ideas.value = ideaData.ideas;
|
||||
criteria.value = ideaData.criteria;
|
||||
conflictOrder.value = ideaData.conflict_order ?? [];
|
||||
decisions.value = log;
|
||||
moreDecisions.value = log.length === PAGE;
|
||||
} catch {
|
||||
@@ -103,7 +110,7 @@ async function confirmPending() {
|
||||
else await retireFamilyIdea(p.id, reason);
|
||||
toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired");
|
||||
pending.value = null;
|
||||
await load();
|
||||
await Promise.all([load(), matrixRef.value?.load()]);
|
||||
} catch (e: unknown) {
|
||||
toast.show(apiErrorMessage(e, p.kind === "undo" ? "Could not undo" : "Could not retire"), "error");
|
||||
} finally {
|
||||
@@ -144,14 +151,44 @@ function evidenceLines(d: FamilyDecision): { label: string; lines: string[] }[]
|
||||
if (typeof ev.trigger === "string") {
|
||||
out.push({ label: "Trigger", lines: [ev.trigger] });
|
||||
}
|
||||
const conflict = ev.conflict as {
|
||||
ground: string; grounds_checked: Record<string, string>; canon: string; other: string;
|
||||
folded_as: string; conditions: { canon: string; other: string } | null;
|
||||
} | undefined;
|
||||
if (conflict) {
|
||||
const title = (k: string) => conflictOrder.value.find((g) => g.key === k)?.title ?? k;
|
||||
const lines = [
|
||||
`Decided by: ${title(conflict.ground)}`,
|
||||
...Object.entries(conflict.grounds_checked || {})
|
||||
.map(([k, why]) => `${title(k)} did not decide — ${why}`),
|
||||
`Canon: ${conflict.canon}; the other side (${conflict.other}) was folded into the note as ${
|
||||
conflict.folded_as === "condition" ? "the branch for its condition" : `a ${conflict.folded_as}`}`,
|
||||
];
|
||||
if (conflict.conditions) {
|
||||
lines.push(`When ${conflict.conditions.canon}: ${conflict.canon}`,
|
||||
`When ${conflict.conditions.other}: ${conflict.other}`);
|
||||
}
|
||||
out.push({ label: "Conflict", lines });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ADOPTION_LABELS: Record<string, string> = {
|
||||
unassessed: "not assessed", adopted: "adopted", variant: "variant",
|
||||
exempt: "doesn't apply", owed: "owed",
|
||||
};
|
||||
|
||||
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}`;
|
||||
if (d.project_id !== null) {
|
||||
const row = a as AdoptionSnapshot;
|
||||
const version = row.canon_version ? ` against v${row.canon_version}` : "";
|
||||
return `${ADOPTION_LABELS[row.status] ?? row.status}${version}`;
|
||||
}
|
||||
const idea = a as IdeaSnapshot;
|
||||
const where = idea.platforms.length ? ` · ${idea.platforms.join(", ")}` : "";
|
||||
return `${idea.status} v${idea.canon_version}${where}`;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -246,6 +283,11 @@ onMounted(load);
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="fam-section" aria-labelledby="fam-matrix">
|
||||
<h2 id="fam-matrix">Adoption</h2>
|
||||
<FamilyAdoptionMatrix ref="matrixRef" />
|
||||
</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>
|
||||
@@ -255,6 +297,10 @@ onMounted(load);
|
||||
<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>
|
||||
<template v-if="d.project_id !== null">
|
||||
<span class="fam-muted">for</span>
|
||||
<router-link :to="`/projects/${d.project_id}`">{{ d.project_title ?? `project ${d.project_id}` }}</router-link>
|
||||
</template>
|
||||
<span class="fam-muted">
|
||||
#{{ d.id }} · {{ d.decided_via }} · {{ d.created_at ? fmtStamp(d.created_at) : "" }}
|
||||
</span>
|
||||
|
||||
@@ -163,9 +163,11 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
# The platform catalog and a project's answers (milestone 463). A pure
|
||||
# read; set_project_platforms is the write.
|
||||
"list_platforms",
|
||||
# Family canon (milestone 463): ideas, one idea with its precedents, and
|
||||
# the decision log. Reads of records the caller can read.
|
||||
# Family canon (milestone 463): ideas, one idea with its precedents, the
|
||||
# decision log, and the adoption ledger. Reads of records the caller can
|
||||
# read.
|
||||
"list_family_ideas", "get_family_idea", "list_family_decisions",
|
||||
"get_family_adoption", "list_family_adoptions",
|
||||
# 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, as list_rules is.
|
||||
@@ -193,6 +195,9 @@ _WRITE_TOOLS = frozenset({
|
||||
# family canon — the promotion engine
|
||||
"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||
"undo_family_decision",
|
||||
# family canon — the adoption ledger
|
||||
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
|
||||
"set_family_references",
|
||||
"bind_repo", "unbind_repo",
|
||||
# snippets, processes, the shape ledger
|
||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||
|
||||
+205
-11
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import family_adoption as adoption_svc
|
||||
|
||||
|
||||
async def list_family_ideas(
|
||||
@@ -32,8 +33,9 @@ async def list_family_ideas(
|
||||
|
||||
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.
|
||||
full decision history, ledger counts, THE THREE CRITERIA, the `ledger`
|
||||
(every project's answer, with `needs_recheck`), 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.
|
||||
|
||||
@@ -45,6 +47,7 @@ async def get_family_idea(note_id: int) -> dict:
|
||||
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)
|
||||
idea["ledger"] = await adoption_svc.list_adoptions(uid, idea_id=note_id)
|
||||
return idea
|
||||
|
||||
|
||||
@@ -146,10 +149,12 @@ async def retire_family_idea(note_id: int, reason: str) -> dict:
|
||||
|
||||
|
||||
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.
|
||||
"""Reverse a family decision — a promotion, a revision, a retirement, a
|
||||
proposal, or one project's assessment — restoring the state it recorded
|
||||
as `before`. Only the latest decision on an idea (or on one project's
|
||||
answer to it) can be undone; the undo is logged too, so the history
|
||||
keeps both. Undoing an owed answer closes its task; undoing back to owed
|
||||
reopens it.
|
||||
|
||||
Args:
|
||||
decision_id: the decision (list_family_decisions).
|
||||
@@ -158,26 +163,215 @@ async def undo_family_decision(decision_id: int, reason: str) -> dict:
|
||||
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:
|
||||
async def list_family_decisions(
|
||||
note_id: int = 0, project_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.
|
||||
veto, revision, retirement, assessment and undo, with its reason,
|
||||
evidence and the precedents it followed. `undoable` marks the decision an
|
||||
undo would reverse — per idea, and per project's answer.
|
||||
|
||||
Args:
|
||||
note_id: only this idea's decisions. 0 = all.
|
||||
project_id: only this project's assessments. 0 = all.
|
||||
limit / offset: page through the log.
|
||||
"""
|
||||
rows = await family_svc.list_decisions(
|
||||
current_user_id(), idea_id=note_id or None,
|
||||
current_user_id(), idea_id=note_id or None, project_id=project_id or None,
|
||||
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||
)
|
||||
return {"decisions": rows, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
async def revise_family_idea(
|
||||
note_id: int, reason: str, applies_when: str = "", platforms: list[str] | None = None,
|
||||
evidence: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Record that a canon idea's SUBSTANCE changed — you rewrote its note's
|
||||
approach, its traps or its checklist, or its applicability or platforms
|
||||
moved. The canon version moves, so every project's answer given against
|
||||
the old version reads `needs_recheck` until it is assessed again.
|
||||
|
||||
A typo fix is not a revision. A change a project that adopted the old
|
||||
version would need to act on is.
|
||||
|
||||
Args:
|
||||
note_id: the canon idea.
|
||||
reason: what changed, in a sentence.
|
||||
applies_when: a new 'when it applies'. Empty = keep the current one.
|
||||
platforms: new platform slugs. Omit = keep the current ones.
|
||||
evidence: what prompted the change, if anything.
|
||||
"""
|
||||
return await family_svc.revise(
|
||||
current_user_id(), note_id, reason=reason,
|
||||
applies_when=applies_when or None, platforms=platforms, evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
async def get_family_adoption(project_id: int, idea_id: int) -> dict:
|
||||
"""Everything one assessment needs: the idea (its 'when it applies',
|
||||
platforms and version), this project's current answer, THE FOUR
|
||||
OUTCOMES, the `precedents` — this idea's answers in other projects and
|
||||
this project's answers to the nearest ideas — and the reference
|
||||
implementations beside this project's languages. Read it before
|
||||
assess_family_adoption.
|
||||
|
||||
Args:
|
||||
project_id: the project answering.
|
||||
idea_id: the canon idea.
|
||||
"""
|
||||
return await adoption_svc.get_adoption(current_user_id(), project_id, idea_id)
|
||||
|
||||
|
||||
async def list_family_adoptions(
|
||||
project_id: int = 0, idea_id: int = 0, status: str = "", needs_recheck: bool = False,
|
||||
platform: str = "",
|
||||
) -> dict:
|
||||
"""The adoption ledger: each project's answer to each canon idea that
|
||||
reaches it — `unassessed`, `adopted`, `variant`, `exempt` or `owed`, with
|
||||
its reason, the canon version it was given against, `needs_recheck` when
|
||||
the idea has moved on since, and the owed task.
|
||||
|
||||
Args:
|
||||
project_id: one project's answers. 0 = all you can read.
|
||||
idea_id: one idea's answers. 0 = all canon.
|
||||
status: one outcome. Empty = all.
|
||||
needs_recheck: only answers given against an older canon version.
|
||||
platform: only ideas for this platform slug.
|
||||
"""
|
||||
rows = await adoption_svc.list_adoptions(
|
||||
current_user_id(), project_id=project_id or None, idea_id=idea_id or None,
|
||||
status=status or None, recheck_only=needs_recheck, platform=platform or None,
|
||||
)
|
||||
return {"adoptions": rows}
|
||||
|
||||
|
||||
async def assess_family_adoption(
|
||||
project_id: int,
|
||||
idea_id: int,
|
||||
outcome: str,
|
||||
reason: str,
|
||||
evidence: list[str] | None = None,
|
||||
precedent_ids: list[int] | None = None,
|
||||
system_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Answer one canon family idea for one project. YOU decide; nobody
|
||||
approves. Judge in this order and stop at the first that holds:
|
||||
|
||||
1. exempt — the idea's 'when it applies' is false for this project.
|
||||
`reason` names the fact about the project that makes it false.
|
||||
2. variant — it applies, and the project departs for a reason that names
|
||||
a FACT about itself the canon did not account for. A preference, a
|
||||
taste, or "we already did it another way" is not a reason: that is
|
||||
owed — or, if this project's way is better rather than different, a
|
||||
conflict (resolve_family_conflict).
|
||||
3. adopted — it applies and the project does it. `evidence` names where
|
||||
(a file, a commit, a task, a CI run).
|
||||
4. owed — none of the above. A task is filed in THIS project naming the
|
||||
gap and the reference implementation for its language. Nothing edits
|
||||
another repository; the project picks the task up itself.
|
||||
|
||||
Read get_family_adoption first: answer consistently with its precedents
|
||||
unless this project differs in a way you can name in `reason`. The
|
||||
engine also records the precedents it found itself.
|
||||
|
||||
The owed task follows the answer: adopted closes it as done, exempt or
|
||||
variant cancels it, owed again reopens it. The same answer given twice
|
||||
records nothing the second time.
|
||||
|
||||
Args:
|
||||
project_id: the project answering.
|
||||
idea_id: the canon idea.
|
||||
outcome: exempt | variant | adopted | owed.
|
||||
reason: why — required for every outcome.
|
||||
evidence: where it is done (required for adopted), or what you checked.
|
||||
precedent_ids: earlier family decisions you followed, if any.
|
||||
system_ids: Systems for an owed task. Omit to match the idea's own.
|
||||
"""
|
||||
return await adoption_svc.assess(
|
||||
current_user_id(), project_id, idea_id, outcome=outcome, reason=reason,
|
||||
evidence=evidence, precedent_ids=precedent_ids, system_ids=system_ids,
|
||||
)
|
||||
|
||||
|
||||
async def resolve_family_conflict(
|
||||
idea_id: int,
|
||||
canon_project_id: int,
|
||||
other_project_id: int,
|
||||
ground: str,
|
||||
fold: str,
|
||||
reason: str,
|
||||
grounds_checked: dict | None = None,
|
||||
evidence: list[str] | None = None,
|
||||
conditions: dict | None = None,
|
||||
precedent_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Settle two projects that solve the same canon idea differently, each
|
||||
for reasons it believes. YOU decide, by THE CONFLICT ORDER — the first
|
||||
ground that applies wins, and for every ground above it you say in
|
||||
`grounds_checked` why it did not decide:
|
||||
|
||||
1. operator_stance — one side follows a stance the operator stated (a
|
||||
rule, a preference, a recorded decision). Name it in `evidence`.
|
||||
2. covers_failure — one side covers a recorded failure (an incident, an
|
||||
issue, a lesson) the other does not. Name it in `evidence`. The other
|
||||
side becomes owed.
|
||||
3. split_by_condition — both are right under different conditions. The
|
||||
canon splits: `conditions={"canon": …, "other": …}`, and each side is
|
||||
canon where its condition holds.
|
||||
4. most_recent_complete — none of the above: the side verified most
|
||||
recently and covering the most wins. Name the verification.
|
||||
|
||||
`canon_project_id` is the side whose approach the idea's note states
|
||||
after this. If the note says something else now, rewrite it first
|
||||
(update_note) — the note is the canon.
|
||||
|
||||
The losing side's reasoning, `fold`, is appended to the idea's note as a
|
||||
trap (ground 2), an alternative (1, 4) or the branch for its condition
|
||||
(3). It is never dropped. The version moves, so every other project's
|
||||
answer reads `needs_recheck`. The canon side is answered adopted; the
|
||||
other owed (with a task in its project) or, in a split, adopted.
|
||||
|
||||
Args:
|
||||
idea_id: the canon idea.
|
||||
canon_project_id: the side whose approach is canon after this.
|
||||
other_project_id: the side that loses, or the split's other branch.
|
||||
ground: operator_stance | covers_failure | split_by_condition | most_recent_complete.
|
||||
fold: the other side's reasoning, as it should read in the note.
|
||||
reason: the decision in one or two sentences.
|
||||
grounds_checked: {earlier ground: why it did not decide}.
|
||||
evidence: the stance, the failure or the verification.
|
||||
conditions: for a split, {"canon": condition, "other": condition}.
|
||||
precedent_ids: earlier family decisions you followed, if any.
|
||||
"""
|
||||
return await adoption_svc.resolve_conflict(
|
||||
current_user_id(), idea_id, canon_project_id=canon_project_id,
|
||||
other_project_id=other_project_id, ground=ground, grounds_checked=grounds_checked,
|
||||
fold=fold, evidence=evidence, reason=reason, conditions=conditions,
|
||||
precedent_ids=precedent_ids,
|
||||
)
|
||||
|
||||
|
||||
async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
|
||||
"""Set a family idea's reference implementations — the snippets an owed
|
||||
task points a project at, ideally one per language. Replaces the list.
|
||||
The idea is what transfers; a reference is where to start, not code to
|
||||
copy.
|
||||
|
||||
Args:
|
||||
note_id: the family idea.
|
||||
snippet_ids: snippets implementing it. [] clears the list.
|
||||
"""
|
||||
refs = await adoption_svc.set_references(current_user_id(), note_id, snippet_ids or [])
|
||||
return {"references": refs}
|
||||
|
||||
|
||||
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,
|
||||
list_family_decisions, revise_family_idea, get_family_adoption,
|
||||
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
|
||||
set_family_references,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""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
|
||||
The agent decides promotions and each project's answers through the MCP
|
||||
tools; this door exists so a person can READ every decision and its reasons
|
||||
— the ideas, the adoption matrix, the log — 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).
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
from scribe.services import family_adoption as adoption_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,7 +40,10 @@ async def list_ideas_route():
|
||||
platform=request.args.get("platform") or None,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)})
|
||||
return jsonify({
|
||||
"ideas": ideas, "criteria": list(family_svc.CRITERIA),
|
||||
"conflict_order": list(adoption_svc.CONFLICT_ORDER),
|
||||
})
|
||||
|
||||
|
||||
@family_bp.route("/ideas/<int:note_id>", methods=["GET"])
|
||||
@@ -100,13 +105,29 @@ async def retire_route(note_id: int):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@family_bp.route("/matrix", methods=["GET"])
|
||||
@login_required
|
||||
async def matrix_route():
|
||||
"""Projects × canon ideas — the adoption matrix. Read-only: the agent
|
||||
answers through assess_family_adoption; a person reads the answers here
|
||||
and undoes one from the decision log if they disagree."""
|
||||
matrix = await adoption_svc.adoption_matrix(
|
||||
get_current_user_id(),
|
||||
platform=request.args.get("platform") or None,
|
||||
project_id=request.args.get("project_id", type=int) or None,
|
||||
)
|
||||
return jsonify(matrix)
|
||||
|
||||
|
||||
@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)
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
rows = await family_svc.list_decisions(
|
||||
get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset,
|
||||
get_current_user_id(), idea_id=idea_id or None, project_id=project_id or None,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
+162
-27
@@ -232,35 +232,52 @@ async def list_ideas(
|
||||
|
||||
|
||||
async def list_decisions(
|
||||
user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0,
|
||||
user_id: int, *, idea_id: int | None = None, project_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."""
|
||||
row says whether it is the one an undo would reverse; a project's
|
||||
assessment also carries the project's title."""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyDecision, Note.title)
|
||||
select(FamilyDecision, Note.title, Project.title)
|
||||
.join(Note, Note.id == FamilyDecision.idea_id)
|
||||
.outerjoin(Project, Project.id == FamilyDecision.project_id)
|
||||
.where(access.readable_notes_clause(user_id))
|
||||
)
|
||||
if idea_id:
|
||||
query = query.where(FamilyDecision.idea_id == idea_id)
|
||||
if project_id:
|
||||
query = query.where(FamilyDecision.project_id == project_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})
|
||||
latest = await _latest_idea_decisions(
|
||||
session, {d.idea_id for d, _, _ in rows if d.project_id is None})
|
||||
latest_pair = await _latest_pair_decisions(
|
||||
session, {(d.idea_id, d.project_id) for d, _, _ in rows if d.project_id is not None})
|
||||
out = []
|
||||
for d, title in rows:
|
||||
for d, title, project_title in rows:
|
||||
item = _decision_dict(d, title)
|
||||
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||
if d.project_id is None:
|
||||
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||
else:
|
||||
item["project_title"] = project_title
|
||||
item["undoable"] = latest_pair.get((d.idea_id, d.project_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
|
||||
"""A decision that changed something: an idea-level one, or one
|
||||
project's assessment. A veto (a `propose` whose before and after agree)
|
||||
changed nothing, and an undo is undone by deciding again, not by undoing
|
||||
the undo."""
|
||||
if d.before == d.after:
|
||||
return False
|
||||
if d.project_id is None:
|
||||
return d.action in _IDEA_ACTIONS
|
||||
return d.action == "assess"
|
||||
|
||||
|
||||
def _undoable_id(decisions_newest_first) -> int | None:
|
||||
@@ -270,6 +287,22 @@ def _undoable_id(decisions_newest_first) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _latest_pair_decisions(session, pairs: set[tuple[int, int]]) -> dict[tuple[int, int], int]:
|
||||
"""The latest decision about each (idea, project) answer — the only one of
|
||||
a project's decisions on an idea that an undo may reverse."""
|
||||
if not pairs:
|
||||
return {}
|
||||
rows = await session.execute(
|
||||
select(FamilyDecision.idea_id, FamilyDecision.project_id, func.max(FamilyDecision.id))
|
||||
.where(
|
||||
FamilyDecision.idea_id.in_({i for i, _ in pairs}),
|
||||
FamilyDecision.project_id.in_({p for _, p in pairs}),
|
||||
)
|
||||
.group_by(FamilyDecision.idea_id, FamilyDecision.project_id)
|
||||
)
|
||||
return {(i, p): d for i, p, d in rows.all() if (i, p) in pairs}
|
||||
|
||||
|
||||
async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||
if not idea_ids:
|
||||
return {}
|
||||
@@ -281,11 +314,10 @@ async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||
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."""
|
||||
async def nearest_ideas(user_id: int, note_id: int, limit: int = 5) -> list[tuple[float, Note]]:
|
||||
"""The family ideas nearest this record by meaning, best first, as
|
||||
(score, note). Empty when there are no other ideas or the embedder is
|
||||
unavailable — precedent is a help to a decision, never a gate on it."""
|
||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -305,7 +337,15 @@ async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
|
||||
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]
|
||||
return [(score, n) for score, n in hits if n.id in idea_ids][:limit]
|
||||
|
||||
|
||||
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."""
|
||||
ranked = await nearest_ideas(user_id, note_id, limit)
|
||||
if not ranked:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
@@ -521,15 +561,8 @@ async def promote(
|
||||
# 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
|
||||
if await _promoted_before(session, note_id):
|
||||
idea.canon_version = await next_version(session, idea)
|
||||
idea.status = "canon"
|
||||
idea.applies_when = applies_when.strip()
|
||||
idea.updated_at = _now()
|
||||
@@ -550,6 +583,100 @@ async def promote(
|
||||
}
|
||||
|
||||
|
||||
async def _promoted_before(session, note_id: int) -> bool:
|
||||
return bool(await session.scalar(
|
||||
select(func.count()).select_from(FamilyDecision)
|
||||
.where(FamilyDecision.idea_id == note_id, FamilyDecision.action == "promote")
|
||||
))
|
||||
|
||||
|
||||
async def next_version(session, idea: FamilyIdea) -> int:
|
||||
"""One past every canon version this idea has ever held — including one
|
||||
an undo rolled back from — so no answer given against an earlier canon
|
||||
can read as agreeing with the new one. Only idea-level snapshots carry an
|
||||
idea version; an assessment's `after` holds the version it was judged
|
||||
against, which is never ahead of the idea's."""
|
||||
rows = (await session.execute(
|
||||
select(FamilyDecision.after).where(
|
||||
FamilyDecision.idea_id == idea.note_id, FamilyDecision.project_id.is_(None),
|
||||
)
|
||||
)).scalars().all()
|
||||
seen = [idea.canon_version or 1] + [int(a.get("canon_version") or 1) for a in rows if a]
|
||||
return max(seen) + 1
|
||||
|
||||
|
||||
async def _close_unreached(session, note_id: int) -> int:
|
||||
"""Delete the `unassessed` rows of projects no longer on any of the
|
||||
idea's platforms — nobody judged them, and the idea no longer reaches
|
||||
them. Judged rows stay, as history."""
|
||||
members = (
|
||||
select(ProjectPlatform.project_id)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
|
||||
.where(FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES))
|
||||
)
|
||||
result = await session.execute(
|
||||
delete(FamilyAdoption).where(
|
||||
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
|
||||
FamilyAdoption.project_id.not_in(members),
|
||||
)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def revise(
|
||||
user_id: int, note_id: int, *, reason: str, applies_when: str | None = None,
|
||||
platforms: list[str] | None = None, evidence: list[str] | None = None,
|
||||
decided_via: str = "agent",
|
||||
) -> dict:
|
||||
"""Record that a canon idea's SUBSTANCE changed — its note was rewritten,
|
||||
its applicability narrowed or widened, or its platforms changed.
|
||||
|
||||
The version moves, so every project's answer given against the earlier
|
||||
version reads as needing a recheck (derived, never a stored flag). A
|
||||
project newly in scope gets an `unassessed` row; an `unassessed` row of a
|
||||
project no longer in scope is closed. Undoable like any idea-level
|
||||
decision.
|
||||
|
||||
`applies_when` and `platforms` are left alone when None; given, they
|
||||
replace the old ones and may not be empty (canon is always scoped).
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("a revision needs a reason — what changed in the idea?")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
if applies_when is not None and not applies_when.strip():
|
||||
raise ValueError("canon needs an 'applies when' — leave it out to keep the current one")
|
||||
if platforms is not None and not [p for p in platforms if (p or "").strip()]:
|
||||
raise ValueError("canon needs at least one platform — leave it out to keep the current ones")
|
||||
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is None or idea.status != "canon":
|
||||
raise ValueError(f"#{note_id} is not family canon — only canon is revised")
|
||||
known = await _resolve_platforms(session, platforms) if platforms is not None else None
|
||||
before = await _snapshot(session, idea)
|
||||
idea.canon_version = await next_version(session, idea)
|
||||
if applies_when is not None:
|
||||
idea.applies_when = applies_when.strip()
|
||||
if known is not None:
|
||||
await _set_platforms(session, note_id, known.values())
|
||||
idea.updated_at = _now()
|
||||
await session.flush()
|
||||
opened = await _open_ledger(session, user_id, note_id)
|
||||
closed = await _close_unreached(session, note_id)
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="revise", reason=reason, before=before,
|
||||
after=await _snapshot(session, idea),
|
||||
evidence={"evidence": evidence, "ledger_rows_opened": opened,
|
||||
"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(),
|
||||
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
|
||||
|
||||
|
||||
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||
ids = [int(i) for i in ids if i]
|
||||
if not ids:
|
||||
@@ -592,8 +719,11 @@ async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str =
|
||||
|
||||
|
||||
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 —
|
||||
"""Reverse a decision, restoring the state it recorded as `before`. A
|
||||
project's assessment is handed to the adoption ledger
|
||||
(family_adoption.undo_assessment); the rest of this is idea-level.
|
||||
|
||||
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.
|
||||
@@ -607,6 +737,11 @@ async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str
|
||||
target = await session.get(FamilyDecision, decision_id)
|
||||
if target is None:
|
||||
raise ValueError(f"no such family decision: {decision_id}")
|
||||
if target.project_id is not None:
|
||||
# One project's answer: the adoption ledger restores the row.
|
||||
from scribe.services import family_adoption
|
||||
return await family_adoption.undo_assessment(
|
||||
user_id, target, reason=reason, decided_via=decided_via)
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,928 @@
|
||||
"""Family canon's adoption ledger (milestone 463 step 4).
|
||||
|
||||
Promotion (services/family.py) decides that an idea is canon for some
|
||||
platforms. This module decides what each project on those platforms does
|
||||
about it, and keeps the answers honest as the canon moves.
|
||||
|
||||
ASSESSMENT — one project, one idea, one of four outcomes, judged in order:
|
||||
|
||||
exempt → adopted/variant/owed are never reached: the idea does not apply.
|
||||
variant → it applies, and the project departs for a FACT about itself.
|
||||
adopted → it applies and the project does it; the evidence says where.
|
||||
owed → none of the above. A task is filed in the project to do it.
|
||||
|
||||
No person approves an assessment. Consistency comes from precedent: every
|
||||
assessment records the answers it was checked against — the same idea's
|
||||
answers in other projects, and this project's answers to the nearest ideas —
|
||||
found by the engine itself, so "which precedents were consulted" is a fact
|
||||
about the call. Asking the same question twice with the same answer records
|
||||
nothing the second time.
|
||||
|
||||
OWED → TASK. An `owed` answer files a task in the OWING project — tagged to
|
||||
the System matching the idea's, naming the gap and the reference
|
||||
implementation for that project's language. Nothing here edits a repository:
|
||||
the work is filed where its own project will pick it up. When the answer
|
||||
moves on, the task follows: `adopted` closes it as done, `exempt` or
|
||||
`variant` cancels it, `owed` again reopens it. Each change is logged on the
|
||||
task with the reason.
|
||||
|
||||
RECHECK. An answer records the canon version it was given against. When the
|
||||
idea's version moves (re-promotion, revision, conflict resolution), every
|
||||
answer given against another version reads `needs_recheck` — DERIVED by
|
||||
comparing the two numbers, never a stored flag that could go stale.
|
||||
|
||||
CONFLICT. When two projects solve the same idea differently and each thinks
|
||||
its way is the right one, the conflict order decides — the first ground that
|
||||
applies wins, and every ground above it must be said not to:
|
||||
|
||||
1. a stance the operator stated;
|
||||
2. the approach that covers a recorded failure (the other becomes owed);
|
||||
3. both right under different conditions (the canon splits by condition);
|
||||
4. the most recently verified and most complete.
|
||||
|
||||
The losing side's reasoning is folded into the idea's note — as a trap, an
|
||||
alternative, or the branch for its condition — and is never dropped. The
|
||||
substance changed, so the version moves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import (
|
||||
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
|
||||
Platform, ProjectPlatform,
|
||||
)
|
||||
from scribe.models.note import Note, TaskStatus
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.system import RecordSystem, System
|
||||
from scribe.services import access
|
||||
from scribe.services import family as family_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- the assessment and the conflict order, as product text --------------------
|
||||
#
|
||||
# Judged on every install (rules 115, 119); the tool docstrings quote them.
|
||||
|
||||
OUTCOMES = (
|
||||
{
|
||||
"key": "exempt",
|
||||
"title": "Does not apply",
|
||||
"test": (
|
||||
"The idea's 'when it applies' is false for this project. The reason "
|
||||
"names the fact about this project that makes it false."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "variant",
|
||||
"title": "Applies, and the project departs",
|
||||
"test": (
|
||||
"It applies, and the project departs for a reason that names a FACT "
|
||||
"about itself the canon did not account for. A preference, a taste, "
|
||||
"or 'we already did it another way' is not a reason: that is owed — "
|
||||
"or, if this project's way is better, a conflict to resolve."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "adopted",
|
||||
"title": "Applies, and the project does it",
|
||||
"test": (
|
||||
"It applies and the project does it. The evidence names where — a "
|
||||
"file, a commit, a task, a CI run."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "owed",
|
||||
"title": "Applies, and is not done yet",
|
||||
"test": (
|
||||
"It applies, nothing above excuses it, and the project does not do "
|
||||
"it yet. A task is filed in this project to do it."
|
||||
),
|
||||
},
|
||||
)
|
||||
OUTCOME_KEYS = tuple(o["key"] for o in OUTCOMES)
|
||||
|
||||
CONFLICT_ORDER = (
|
||||
{
|
||||
"key": "operator_stance",
|
||||
"title": "A stance the operator stated",
|
||||
"test": (
|
||||
"One side follows a stance the operator stated — a rule, a "
|
||||
"preference, a recorded decision. It wins over anything inferred. "
|
||||
"Name the stance in the evidence."
|
||||
),
|
||||
"fold_as": "alternative",
|
||||
},
|
||||
{
|
||||
"key": "covers_failure",
|
||||
"title": "Covers a recorded failure",
|
||||
"test": (
|
||||
"One side covers a failure that is on record — an incident, an "
|
||||
"issue, a lesson — and the other does not. Name the failure in the "
|
||||
"evidence. The other side becomes owed."
|
||||
),
|
||||
"fold_as": "trap",
|
||||
},
|
||||
{
|
||||
"key": "split_by_condition",
|
||||
"title": "Both right, under different conditions",
|
||||
"test": (
|
||||
"Each side is right under a condition the other does not meet. The "
|
||||
"canon splits: each approach is canon where its condition holds. "
|
||||
"State both conditions."
|
||||
),
|
||||
"fold_as": "condition",
|
||||
},
|
||||
{
|
||||
"key": "most_recent_complete",
|
||||
"title": "Most recently verified and most complete",
|
||||
"test": (
|
||||
"None of the above decides. The side verified most recently, and "
|
||||
"covering the most, wins. Name the verification in the evidence."
|
||||
),
|
||||
"fold_as": "alternative",
|
||||
},
|
||||
)
|
||||
CONFLICT_KEYS = tuple(c["key"] for c in CONFLICT_ORDER)
|
||||
_GROUND = {c["key"]: c for c in CONFLICT_ORDER}
|
||||
|
||||
_OPEN_TASK = (TaskStatus.todo.value, TaskStatus.in_progress.value)
|
||||
|
||||
|
||||
def _clean(items) -> list[str]:
|
||||
return [str(e).strip() for e in items or [] if str(e).strip()]
|
||||
|
||||
|
||||
# --- the pure checks ---------------------------------------------------------------
|
||||
|
||||
def assessment_problems(*, outcome: str, reason: str, evidence: list | None) -> list[str]:
|
||||
"""What an assessment is missing before it can be recorded. Pure.
|
||||
|
||||
Every outcome needs a reason — it is what the next assessment of a
|
||||
similar idea is checked against. `adopted` also needs evidence naming
|
||||
where the project does it.
|
||||
"""
|
||||
if outcome not in OUTCOME_KEYS:
|
||||
return [f"outcome must be one of: {', '.join(OUTCOME_KEYS)}"]
|
||||
problems = []
|
||||
if not (reason or "").strip():
|
||||
problems.append({
|
||||
"exempt": "exempt needs a reason naming the fact that makes 'when it applies' false here",
|
||||
"variant": "variant needs a reason naming the fact about this project the canon missed",
|
||||
"adopted": "adopted needs a reason",
|
||||
"owed": "owed needs a reason naming the gap",
|
||||
}[outcome])
|
||||
if outcome == "adopted" and not _clean(evidence):
|
||||
problems.append("adopted needs evidence naming where the project does it")
|
||||
return problems
|
||||
|
||||
|
||||
def conflict_problems(
|
||||
*, ground: str, grounds_checked: dict | None, evidence: list | None,
|
||||
conditions: dict | None, fold: str,
|
||||
) -> list[str]:
|
||||
"""What a conflict resolution is missing before it can be recorded. Pure.
|
||||
|
||||
The order is enforced, not suggested: every ground ABOVE the deciding one
|
||||
must carry a sentence saying why it did not decide. Grounds 1, 2 and 4
|
||||
each need evidence (the stance, the failure, the verification); a split
|
||||
needs both conditions; and the losing side's reasoning is required,
|
||||
because it is folded into the idea rather than dropped.
|
||||
"""
|
||||
if ground not in CONFLICT_KEYS:
|
||||
return [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
|
||||
checked = grounds_checked or {}
|
||||
problems = [
|
||||
f"'{k}' comes before '{ground}' in the conflict order — say why it did not "
|
||||
f"decide (grounds_checked['{k}'])"
|
||||
for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
|
||||
if not str(checked.get(k) or "").strip()
|
||||
]
|
||||
if ground == "split_by_condition":
|
||||
conds = conditions or {}
|
||||
if not str(conds.get("canon") or "").strip() or not str(conds.get("other") or "").strip():
|
||||
problems.append(
|
||||
"a split needs both conditions: conditions={'canon': …, 'other': …}")
|
||||
elif not _clean(evidence):
|
||||
problems.append({
|
||||
"operator_stance": "name the operator's stance in evidence (a rule, a preference, a decision)",
|
||||
"covers_failure": "name the recorded failure in evidence (an incident, an issue, a lesson)",
|
||||
"most_recent_complete": "name the verification in evidence (a CI run, a device check, a date)",
|
||||
}[ground])
|
||||
if not (fold or "").strip():
|
||||
problems.append(
|
||||
"the losing side's reasoning is folded into the idea, never dropped — `fold` is required")
|
||||
return problems
|
||||
|
||||
|
||||
def needs_recheck(row_status: str, row_version: int | None, idea_status: str,
|
||||
idea_version: int) -> bool:
|
||||
"""An answer given against a canon version that is not the current one.
|
||||
Unassessed rows have nothing to recheck; a retired idea asks nothing."""
|
||||
return (
|
||||
idea_status == "canon" and row_status != "unassessed"
|
||||
and row_version is not None and row_version != idea_version
|
||||
)
|
||||
|
||||
|
||||
def _row_state(row: FamilyAdoption) -> dict:
|
||||
"""An answer as a decision's before/after. No ids (the #3182 trap): the
|
||||
owed task is the row's column, not part of the snapshot."""
|
||||
return {"status": row.status, "reason": row.reason or "", "canon_version": row.canon_version}
|
||||
|
||||
|
||||
# --- reads -------------------------------------------------------------------------
|
||||
|
||||
async def _row(session, project_id: int, idea_id: int) -> FamilyAdoption | None:
|
||||
return (await session.execute(
|
||||
select(FamilyAdoption).where(
|
||||
FamilyAdoption.project_id == project_id, FamilyAdoption.idea_id == idea_id)
|
||||
)).scalars().first()
|
||||
|
||||
|
||||
async def _is_member(session, project_id: int, idea_id: int) -> bool:
|
||||
return bool(await session.scalar(
|
||||
select(func.count()).select_from(ProjectPlatform)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
|
||||
.where(
|
||||
ProjectPlatform.project_id == project_id,
|
||||
FamilyIdeaPlatform.note_id == idea_id,
|
||||
ProjectPlatform.state.in_(family_svc.MEMBER_STATES),
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
async def _references(session, idea_id: int) -> list[dict]:
|
||||
rows = (await session.execute(
|
||||
select(Note)
|
||||
.join(FamilyIdeaReference, FamilyIdeaReference.snippet_id == Note.id)
|
||||
.where(FamilyIdeaReference.idea_id == idea_id, Note.deleted_at.is_(None))
|
||||
.order_by(Note.id.asc())
|
||||
)).scalars().all()
|
||||
return [
|
||||
{"id": n.id, "title": n.title,
|
||||
"language": str((n.data or {}).get("language") or "").strip().lower()}
|
||||
for n in rows
|
||||
]
|
||||
|
||||
|
||||
async def _project_languages(session, project_id: int) -> list[str]:
|
||||
"""The languages of the snippets recorded in a project, most used first —
|
||||
what "the reference for this project's language" is matched against."""
|
||||
lang = Note.data["language"].astext
|
||||
rows = (await session.execute(
|
||||
select(func.lower(lang), func.count())
|
||||
.where(Note.project_id == project_id, Note.note_type == "snippet",
|
||||
Note.deleted_at.is_(None), lang.is_not(None), lang != "")
|
||||
.group_by(func.lower(lang)).order_by(func.count().desc())
|
||||
)).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
async def assessment_precedents(user_id: int, project_id: int, idea_id: int,
|
||||
limit: int = 5) -> list[dict]:
|
||||
"""What an assessment should be consistent with: the latest answer to
|
||||
THIS idea in each other project the caller can read, then this project's
|
||||
latest answers to the ideas nearest this one by meaning."""
|
||||
async with async_session() as session:
|
||||
same = (await session.execute(
|
||||
select(func.max(FamilyDecision.id))
|
||||
.where(FamilyDecision.idea_id == idea_id, FamilyDecision.action == "assess",
|
||||
FamilyDecision.project_id.is_not(None),
|
||||
FamilyDecision.project_id != project_id)
|
||||
.group_by(FamilyDecision.project_id)
|
||||
)).scalars().all()
|
||||
near = await family_svc.nearest_ideas(user_id, idea_id, limit=3)
|
||||
near_ids = [n.id for _, n in near]
|
||||
async with async_session() as session:
|
||||
mine = (await session.execute(
|
||||
select(func.max(FamilyDecision.id))
|
||||
.where(FamilyDecision.idea_id.in_(near_ids), FamilyDecision.action == "assess",
|
||||
FamilyDecision.project_id == project_id)
|
||||
.group_by(FamilyDecision.idea_id)
|
||||
)).scalars().all() if near_ids else []
|
||||
rows = (await session.execute(
|
||||
select(FamilyDecision, Note.title, Project.title)
|
||||
.join(Note, Note.id == FamilyDecision.idea_id)
|
||||
.join(Project, Project.id == FamilyDecision.project_id)
|
||||
.where(FamilyDecision.id.in_(list(same) + list(mine)))
|
||||
)).all()
|
||||
scores = {n.id: round(float(s), 3) for s, n in near}
|
||||
out_same, out_near = [], []
|
||||
for d, idea_title, project_title in rows:
|
||||
if not await access.can_read_project(user_id, d.project_id):
|
||||
continue
|
||||
item = d.to_dict()
|
||||
item.update({"idea_title": idea_title, "project_title": project_title})
|
||||
if d.idea_id == idea_id:
|
||||
item["relation"] = "same idea, another project"
|
||||
out_same.append(item)
|
||||
else:
|
||||
item["relation"] = "nearest idea, this project"
|
||||
item["similarity"] = scores.get(d.idea_id)
|
||||
out_near.append(item)
|
||||
out_same.sort(key=lambda x: -x["id"])
|
||||
out_near.sort(key=lambda x: -(x.get("similarity") or 0))
|
||||
return (out_same + out_near)[:limit]
|
||||
|
||||
|
||||
async def adoption_matrix(
|
||||
user_id: int, *, platform: str | None = None, project_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Projects × canon ideas: every answer, and every project an idea
|
||||
reaches that has none yet.
|
||||
|
||||
A cell exists where the project is on one of the idea's platforms, or
|
||||
has an answer from before the idea's scope changed. A member project with
|
||||
no row reads `unassessed` with `reached: False` — the promoter could not
|
||||
write it, so nobody has been asked. Projects and ideas the caller cannot
|
||||
read are left out.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyIdea, Note.title, Note.note_type, Note.status)
|
||||
.join(Note, Note.id == FamilyIdea.note_id)
|
||||
.where(FamilyIdea.status == "canon", Note.deleted_at.is_(None),
|
||||
access.readable_notes_clause(user_id))
|
||||
)
|
||||
if platform:
|
||||
query = query.where(FamilyIdea.note_id.in_(
|
||||
select(FamilyIdeaPlatform.note_id)
|
||||
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
|
||||
.where(Platform.slug == platform)
|
||||
))
|
||||
ideas = (await session.execute(query.order_by(Note.title.asc()))).all()
|
||||
idea_ids = [i.note_id for i, *_ in ideas]
|
||||
idea_platforms: dict[int, dict[int, str]] = {i: {} for i in idea_ids}
|
||||
for nid, pid, slug in (await session.execute(
|
||||
select(FamilyIdeaPlatform.note_id, Platform.id, Platform.slug)
|
||||
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
|
||||
.where(FamilyIdeaPlatform.note_id.in_(idea_ids))
|
||||
.order_by(Platform.order_index.asc(), Platform.slug.asc())
|
||||
)).all():
|
||||
idea_platforms[nid][pid] = slug
|
||||
platform_ids = {pid for m in idea_platforms.values() for pid in m}
|
||||
membership: dict[int, set[int]] = {}
|
||||
for pid, plat in (await session.execute(
|
||||
select(ProjectPlatform.project_id, ProjectPlatform.platform_id)
|
||||
.where(ProjectPlatform.platform_id.in_(platform_ids),
|
||||
ProjectPlatform.state.in_(family_svc.MEMBER_STATES))
|
||||
)).all():
|
||||
membership.setdefault(pid, set()).add(plat)
|
||||
rows = (await session.execute(
|
||||
select(FamilyAdoption).where(FamilyAdoption.idea_id.in_(idea_ids))
|
||||
)).scalars().all()
|
||||
project_ids = set(membership) | {r.project_id for r in rows}
|
||||
if project_id:
|
||||
project_ids &= {project_id}
|
||||
projects = (await session.execute(
|
||||
select(Project.id, Project.title)
|
||||
.where(Project.id.in_(project_ids), Project.deleted_at.is_(None))
|
||||
.order_by(Project.title.asc())
|
||||
)).all()
|
||||
task_ids = [r.owed_task_id for r in rows if r.owed_task_id]
|
||||
tasks = {
|
||||
t.id: t for t in (await session.execute(
|
||||
select(Note).where(Note.id.in_(task_ids), Note.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
} if task_ids else {}
|
||||
|
||||
readable = [(pid, title) for pid, title in projects
|
||||
if await access.can_read_project(user_id, pid)]
|
||||
by_pair = {(r.project_id, r.idea_id): r for r in rows}
|
||||
idea_meta = {i.note_id: (i, title) for i, title, *_ in ideas}
|
||||
cells = []
|
||||
for pid, ptitle in readable:
|
||||
for iid in idea_ids:
|
||||
idea, ititle = idea_meta[iid]
|
||||
row = by_pair.get((pid, iid))
|
||||
reached = bool(membership.get(pid, set()) & set(idea_platforms[iid]))
|
||||
if row is None and not reached:
|
||||
continue
|
||||
cell = {
|
||||
"project_id": pid, "project_title": ptitle,
|
||||
"idea_id": iid, "idea_title": ititle,
|
||||
"idea_version": idea.canon_version,
|
||||
"in_scope": reached,
|
||||
"reached": row is not None,
|
||||
"status": row.status if row else "unassessed",
|
||||
"reason": (row.reason or "") if row else "",
|
||||
"canon_version": row.canon_version if row else None,
|
||||
"assessed_at": row.to_dict()["assessed_at"] if row else None,
|
||||
"decided_via": row.decided_via if row else None,
|
||||
"needs_recheck": bool(row) and needs_recheck(
|
||||
row.status, row.canon_version, idea.status, idea.canon_version),
|
||||
"owed_task": None,
|
||||
}
|
||||
task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None
|
||||
if task is not None:
|
||||
cell["owed_task"] = {"id": task.id, "title": task.title, "status": task.status}
|
||||
cells.append(cell)
|
||||
|
||||
seen_projects = {c["project_id"] for c in cells}
|
||||
return {
|
||||
"ideas": [
|
||||
{
|
||||
"note_id": i.note_id, "title": title, "note_type": note_type or "note",
|
||||
"is_task": note_status is not None, "canon_version": i.canon_version,
|
||||
"applies_when": i.applies_when or "",
|
||||
"platforms": list(idea_platforms[i.note_id].values()),
|
||||
}
|
||||
for i, title, note_type, note_status in ideas
|
||||
],
|
||||
"projects": [
|
||||
{"id": pid, "title": title,
|
||||
"platforms": sorted({s for iid in idea_ids
|
||||
for p, s in idea_platforms[iid].items()
|
||||
if p in membership.get(pid, set())})}
|
||||
for pid, title in readable if pid in seen_projects
|
||||
],
|
||||
"cells": cells,
|
||||
"outcomes": list(OUTCOMES),
|
||||
}
|
||||
|
||||
|
||||
async def list_adoptions(
|
||||
user_id: int, *, project_id: int | None = None, idea_id: int | None = None,
|
||||
status: str | None = None, recheck_only: bool = False, platform: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""The ledger as rows — the matrix's cells, filtered."""
|
||||
matrix = await adoption_matrix(user_id, platform=platform, project_id=project_id)
|
||||
return [
|
||||
c for c in matrix["cells"]
|
||||
if (not idea_id or c["idea_id"] == idea_id)
|
||||
and (not status or c["status"] == status)
|
||||
and (not recheck_only or c["needs_recheck"])
|
||||
]
|
||||
|
||||
|
||||
async def get_adoption(user_id: int, project_id: int, idea_id: int) -> dict:
|
||||
"""Everything one assessment reads: the idea, this project's current
|
||||
answer, the four outcomes, the precedents, and the reference
|
||||
implementations with this project's languages."""
|
||||
if not await access.can_read_project(user_id, project_id):
|
||||
raise ValueError(f"project {project_id} not found")
|
||||
idea = await family_svc.get_idea(user_id, idea_id)
|
||||
if idea is None:
|
||||
raise ValueError(f"#{idea_id} is not a family idea you can read")
|
||||
rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id)
|
||||
async with async_session() as session:
|
||||
refs = await _references(session, idea_id)
|
||||
langs = await _project_languages(session, project_id)
|
||||
idea.pop("decisions", None)
|
||||
return {
|
||||
"idea": idea,
|
||||
"adoption": rows[0] if rows else None,
|
||||
"outcomes": list(OUTCOMES),
|
||||
"precedents": await assessment_precedents(user_id, project_id, idea_id),
|
||||
"references": refs,
|
||||
"project_languages": langs,
|
||||
}
|
||||
|
||||
|
||||
# --- the owed task -----------------------------------------------------------------
|
||||
|
||||
def _reference_line(refs: list[dict], langs: list[str], idea_id: int) -> str:
|
||||
def fmt(r):
|
||||
return f"#{r['id']} “{r['title']}”" + (f" ({r['language']})" if r["language"] else "")
|
||||
|
||||
if not refs:
|
||||
return (f"No reference implementation is recorded yet — build from the idea's "
|
||||
f"note, #{idea_id}.")
|
||||
mine = [r for r in refs if r["language"] and r["language"] in langs]
|
||||
if mine:
|
||||
return "; ".join(fmt(r) for r in mine)
|
||||
said = ", ".join(langs) if langs else "not yet known"
|
||||
return (f"None is recorded in this project's language ({said}). The idea is what "
|
||||
f"transfers; the ones that exist: {'; '.join(fmt(r) for r in refs)}.")
|
||||
|
||||
|
||||
async def _matching_systems(session, project_id: int, note_ids: list[int]) -> list[int]:
|
||||
"""The project's Systems matching the idea's: the same canonical area as
|
||||
one the idea (or a reference) is filed under, or that very System when
|
||||
the idea was written in this project."""
|
||||
tagged = (await session.execute(
|
||||
select(System.id, System.project_id, System.canonical_id)
|
||||
.join(RecordSystem, RecordSystem.system_id == System.id)
|
||||
.where(RecordSystem.note_id.in_(note_ids), System.deleted_at.is_(None))
|
||||
)).all()
|
||||
direct = [sid for sid, pid, _ in tagged if pid == project_id]
|
||||
canonical = {cid for _, _, cid in tagged if cid is not None}
|
||||
mapped = (await session.execute(
|
||||
select(System.id).where(
|
||||
System.project_id == project_id, System.canonical_id.in_(canonical),
|
||||
System.deleted_at.is_(None))
|
||||
.order_by(System.order_index.asc(), System.id.asc())
|
||||
)).scalars().all() if canonical else []
|
||||
return list(dict.fromkeys(direct + list(mapped)))
|
||||
|
||||
|
||||
async def _file_owed_task(user_id: int, row_id: int, reason: str,
|
||||
system_ids: list[int] | None) -> Note:
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
async with async_session() as session:
|
||||
row = await session.get(FamilyAdoption, row_id)
|
||||
idea = await session.get(FamilyIdea, row.idea_id)
|
||||
note = await session.get(Note, row.idea_id)
|
||||
refs = await _references(session, row.idea_id)
|
||||
langs = await _project_languages(session, row.project_id)
|
||||
platforms = await family_svc._platform_slugs(session, row.idea_id)
|
||||
systems = system_ids or await _matching_systems(
|
||||
session, row.project_id, [row.idea_id] + [r["id"] for r in refs])
|
||||
project_id, idea_id, version = row.project_id, row.idea_id, idea.canon_version
|
||||
body = (
|
||||
f"Family idea #{idea_id} “{note.title}” is canon for "
|
||||
f"{', '.join(platforms) or 'its platforms'}, applies to this project, and is "
|
||||
f"not done here yet.\n\n"
|
||||
f"**When it applies:** {idea.applies_when or '—'}\n\n"
|
||||
f"**The gap:** {reason.strip()}\n\n"
|
||||
f"**Start from:** {_reference_line(refs, langs, idea_id)}\n\n"
|
||||
"Build the idea in this project's own language and conventions: what the "
|
||||
"family shares is the idea, its traps and its checklist, not the code. When "
|
||||
"it is done, assess it again as `adopted` (assess_family_adoption) with where "
|
||||
"it lives as evidence; that closes this task. If it turns out not to apply, "
|
||||
"or this project has a reason to depart that is a fact about itself, assess "
|
||||
"it `exempt` or `variant` instead.\n\n"
|
||||
f"_Filed by the family adoption ledger against canon version {version}._"
|
||||
)
|
||||
task = await notes_svc.create_note(
|
||||
user_id, title=f"Adopt the family idea “{note.title}”"[:500], body=body,
|
||||
project_id=project_id, status=TaskStatus.todo.value, task_kind="work",
|
||||
)
|
||||
if systems:
|
||||
await systems_svc.set_record_systems(user_id, task.id, systems)
|
||||
return task
|
||||
|
||||
|
||||
async def _set_task_status(user_id: int, task: Note, status: str, log: str) -> None:
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import task_logs
|
||||
|
||||
# The writer is checked; the write goes through the owner's door, which
|
||||
# is the one that keeps versions, claims and the embedding in step.
|
||||
await notes_svc.update_note(task.user_id, task.id, status=status)
|
||||
await task_logs.create_log(user_id, task.id, log)
|
||||
|
||||
|
||||
async def _sync_owed_task(user_id: int, row_id: int, *, reason: str,
|
||||
system_ids: list[int] | None = None) -> dict | None:
|
||||
"""Make the owed task agree with the answer. Idempotent: an `owed` row
|
||||
with an open task, or a settled row with a closed one, is left alone.
|
||||
A task the caller cannot write is left alone too, and said so."""
|
||||
async with async_session() as session:
|
||||
row = await session.get(FamilyAdoption, row_id)
|
||||
task = await session.get(Note, row.owed_task_id) if row.owed_task_id else None
|
||||
if task is not None and task.deleted_at is not None:
|
||||
task = None
|
||||
status, idea_id = row.status, row.idea_id
|
||||
|
||||
def ref(t: Note, s: str, action: str | None = None) -> dict:
|
||||
out = {"id": t.id, "title": t.title, "status": s}
|
||||
if action:
|
||||
out["action"] = action
|
||||
return out
|
||||
|
||||
if status == "owed":
|
||||
if task is not None and task.status in _OPEN_TASK:
|
||||
return ref(task, task.status)
|
||||
if task is not None and await access.can_write_note(user_id, task.id):
|
||||
await _set_task_status(
|
||||
user_id, task, TaskStatus.todo.value,
|
||||
f"Reopened: family idea #{idea_id} was assessed owed again — {reason}")
|
||||
return ref(task, TaskStatus.todo.value, "reopened")
|
||||
task = await _file_owed_task(user_id, row_id, reason, system_ids)
|
||||
async with async_session() as session:
|
||||
row = await session.get(FamilyAdoption, row_id)
|
||||
row.owed_task_id = task.id
|
||||
await session.commit()
|
||||
return ref(task, task.status, "filed")
|
||||
|
||||
if task is None:
|
||||
return None
|
||||
if task.status not in _OPEN_TASK:
|
||||
return ref(task, task.status)
|
||||
if not await access.can_write_note(user_id, task.id):
|
||||
return ref(task, task.status, "left open — no write access to the task")
|
||||
new = TaskStatus.done.value if status == "adopted" else TaskStatus.cancelled.value
|
||||
if status == "unassessed":
|
||||
line = f"Family idea #{idea_id}'s owed answer was undone — {reason}"
|
||||
else:
|
||||
line = f"Family idea #{idea_id} was assessed {status} in this project — {reason}"
|
||||
await _set_task_status(user_id, task, new, line)
|
||||
return ref(task, new, "closed")
|
||||
|
||||
|
||||
# --- writes -------------------------------------------------------------------------
|
||||
|
||||
async def _row_for_write(session, project_id: int, idea_id: int) -> FamilyAdoption:
|
||||
row = await _row(session, project_id, idea_id)
|
||||
if row is not None:
|
||||
return row
|
||||
if not await _is_member(session, project_id, idea_id):
|
||||
raise ValueError(
|
||||
f"project {project_id} is not on any of #{idea_id}'s platforms, so the idea "
|
||||
"does not reach it — answer the project's platforms first if it should")
|
||||
row = FamilyAdoption(project_id=project_id, idea_id=idea_id, status="unassessed")
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int,
|
||||
decided_via: str) -> None:
|
||||
row.status = status
|
||||
row.reason = reason.strip() or None
|
||||
row.canon_version = version
|
||||
row.assessed_at = family_svc._now()
|
||||
row.decided_via = decided_via
|
||||
row.updated_at = family_svc._now()
|
||||
|
||||
|
||||
async def assess(
|
||||
user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str,
|
||||
evidence: list[str] | None = None, precedent_ids: list[int] | None = None,
|
||||
system_ids: list[int] | None = None, decided_via: str = "agent",
|
||||
) -> dict:
|
||||
"""Record one project's answer to one canon idea.
|
||||
|
||||
Raises ValueError before writing anything when the answer is malformed
|
||||
(assessment_problems), the caller cannot write the project, or the idea
|
||||
is not canon or does not reach the project.
|
||||
|
||||
The same answer given again — same outcome, reason and canon version —
|
||||
records nothing (`changed: False`); it still makes sure an owed answer
|
||||
has an open task. The response carries the precedents consulted, and
|
||||
`owed_task` when there is one.
|
||||
"""
|
||||
evidence = _clean(evidence)
|
||||
problems = assessment_problems(outcome=outcome, reason=reason, evidence=evidence)
|
||||
if problems:
|
||||
raise ValueError("; ".join(problems))
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
raise ValueError(f"project {project_id} not found or no write access")
|
||||
if not await access.can_read_note(user_id, idea_id):
|
||||
raise ValueError(f"#{idea_id} is not a family idea you can read")
|
||||
consulted = await assessment_precedents(user_id, project_id, idea_id)
|
||||
named = await family_svc._existing_decision_ids(precedent_ids or [])
|
||||
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
|
||||
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, idea_id)
|
||||
if idea is None or idea.status != "canon":
|
||||
raise ValueError(f"#{idea_id} is not family canon — only canon is assessed")
|
||||
row = await _row_for_write(session, project_id, idea_id)
|
||||
before = _row_state(row)
|
||||
after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version}
|
||||
changed = before != after
|
||||
decision = None
|
||||
if changed:
|
||||
_answer(row, status=outcome, reason=reason, version=idea.canon_version,
|
||||
decided_via=decided_via)
|
||||
decision = family_svc._log(
|
||||
session, idea_id=idea_id, project_id=project_id, action="assess",
|
||||
reason=reason, before=before, after=_row_state(row),
|
||||
evidence={"evidence": evidence}, precedent_ids=precedent_list,
|
||||
decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
if decision is not None:
|
||||
await session.refresh(decision)
|
||||
row_id = row.id
|
||||
task = await _sync_owed_task(user_id, row_id, reason=reason, system_ids=system_ids)
|
||||
rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id)
|
||||
return {
|
||||
"changed": changed,
|
||||
"adoption": rows[0] if rows else None,
|
||||
"decision": decision.to_dict() if decision is not None else None,
|
||||
"precedents": consulted,
|
||||
"owed_task": task,
|
||||
}
|
||||
|
||||
|
||||
async def undo_assessment(user_id: int, target: FamilyDecision, *, reason: str,
|
||||
decided_via: str = "agent") -> dict:
|
||||
"""Reverse one project's answer, restoring the row it recorded as
|
||||
`before`. Only the latest decision on that (idea, project) answer can be
|
||||
undone. The owed task follows the restored answer."""
|
||||
if not await access.can_write_project(user_id, target.project_id):
|
||||
raise ValueError(f"decision {target.id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
latest = (await family_svc._latest_pair_decisions(
|
||||
session, {(target.idea_id, target.project_id)})).get((target.idea_id, target.project_id))
|
||||
if latest != target.id:
|
||||
raise ValueError(
|
||||
f"decision {target.id} cannot be undone: decision {latest} came after it — "
|
||||
"undo that one first")
|
||||
if not family_svc._can_undo(target):
|
||||
raise ValueError(f"decision {target.id} cannot be undone: it changed nothing")
|
||||
row = await _row(session, target.project_id, target.idea_id)
|
||||
if row is None:
|
||||
raise ValueError(f"decision {target.id} cannot be undone: the answer it changed is gone")
|
||||
before = _row_state(row)
|
||||
prior = target.before or {"status": "unassessed", "reason": "", "canon_version": None}
|
||||
row.status = prior["status"]
|
||||
row.reason = (prior.get("reason") or "").strip() or None
|
||||
row.canon_version = prior.get("canon_version")
|
||||
if row.status == "unassessed":
|
||||
row.assessed_at = None
|
||||
row.decided_via = None
|
||||
else:
|
||||
row.assessed_at = family_svc._now()
|
||||
row.decided_via = decided_via
|
||||
row.updated_at = family_svc._now()
|
||||
decision = family_svc._log(
|
||||
session, idea_id=target.idea_id, project_id=target.project_id, action="undo",
|
||||
reason=reason, before=before, after=_row_state(row),
|
||||
evidence={"undid_action": target.action}, precedent_ids=[target.id],
|
||||
decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
row_id = row.id
|
||||
task = await _sync_owed_task(user_id, row_id, reason=f"undo of decision {target.id}: {reason}")
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, target.idea_id)
|
||||
row = await session.get(FamilyAdoption, row_id)
|
||||
adoption = row.to_dict()
|
||||
return {"idea": idea.to_dict(), "adoption": adoption,
|
||||
"decision": decision.to_dict(), "owed_task": task}
|
||||
|
||||
|
||||
async def set_references(user_id: int, idea_id: int, snippet_ids: list[int]) -> list[dict]:
|
||||
"""Replace an idea's reference implementations — the snippets an owed
|
||||
task points a project at, one per language. Each must be a snippet the
|
||||
caller can read; the idea must be theirs to write."""
|
||||
if not await access.can_write_note(user_id, idea_id):
|
||||
raise ValueError(f"note {idea_id} not found or no write access")
|
||||
wanted = list(dict.fromkeys(int(i) for i in snippet_ids or [] if i))
|
||||
async with async_session() as session:
|
||||
if await session.get(FamilyIdea, idea_id) is None:
|
||||
raise ValueError(f"#{idea_id} is not a family idea")
|
||||
found = {n.id: n for n in (await session.execute(
|
||||
select(Note).where(Note.id.in_(wanted), Note.deleted_at.is_(None))
|
||||
)).scalars().all()} if wanted else {}
|
||||
bad = [i for i in wanted
|
||||
if i not in found or (found[i].note_type or "") != "snippet"]
|
||||
if bad:
|
||||
raise ValueError(f"not a snippet: {', '.join(map(str, bad))}")
|
||||
for i in wanted:
|
||||
if not await access.can_read_note(user_id, i):
|
||||
raise ValueError(f"not a snippet: {i}")
|
||||
current = set((await session.execute(
|
||||
select(FamilyIdeaReference.snippet_id).where(FamilyIdeaReference.idea_id == idea_id)
|
||||
)).scalars().all())
|
||||
for sid in current - set(wanted):
|
||||
await session.delete(await session.get(FamilyIdeaReference, (idea_id, sid)))
|
||||
for sid in wanted:
|
||||
if sid not in current:
|
||||
session.add(FamilyIdeaReference(idea_id=idea_id, snippet_id=sid))
|
||||
await session.commit()
|
||||
return await _references(session, idea_id)
|
||||
|
||||
|
||||
def _fold_section(*, ground: str, fold: str, conditions: dict | None,
|
||||
canon_title: str, other_title: str, when: str) -> str:
|
||||
g = _GROUND[ground]
|
||||
if g["fold_as"] == "condition":
|
||||
conds = conditions or {}
|
||||
return (
|
||||
f"### When {conds['other'].strip()} — {other_title}'s approach\n\n"
|
||||
f"{fold.strip()}\n\n"
|
||||
f"When {conds['canon'].strip()}, {canon_title}'s approach above is the canon. "
|
||||
f"_(Family conflict, {when}: {g['title'].lower()}.)_"
|
||||
)
|
||||
heading = "Trap" if g["fold_as"] == "trap" else "Alternative"
|
||||
return (
|
||||
f"### {heading} — {other_title}'s approach\n\n{fold.strip()}\n\n"
|
||||
f"_(Family conflict, {when}: decided by {g['title'].lower()}; "
|
||||
f"{canon_title}'s approach is the canon.)_"
|
||||
)
|
||||
|
||||
|
||||
async def resolve_conflict(
|
||||
user_id: int, idea_id: int, *, canon_project_id: int, other_project_id: int,
|
||||
ground: str, grounds_checked: dict | None, fold: str, evidence: list[str] | None,
|
||||
reason: str, conditions: dict | None = None, precedent_ids: list[int] | None = None,
|
||||
decided_via: str = "agent",
|
||||
) -> dict:
|
||||
"""Settle two projects that solve the same canon idea differently, by
|
||||
the conflict order.
|
||||
|
||||
`canon_project_id` is the side whose approach the idea's note states
|
||||
after this — if that is not what the note says now, rewrite the note
|
||||
first (update_note); the note is the canon. `other_project_id` is the
|
||||
side that loses, or in a split, the side whose condition is the branch.
|
||||
|
||||
Effects, in order: the losing side's reasoning (`fold`) is appended to
|
||||
the idea's note — as a trap, an alternative, or the branch for its
|
||||
condition; the version moves (a `revise` decision carrying the ground and
|
||||
the grounds checked above it); the canon side is answered `adopted`, and
|
||||
the other side `owed` (with a task filed) or, in a split, `adopted` under
|
||||
its condition. Each answer is its own `assess` decision, naming the
|
||||
revision as its precedent.
|
||||
"""
|
||||
from scribe.services import notes as notes_svc
|
||||
|
||||
evidence = _clean(evidence)
|
||||
problems = conflict_problems(ground=ground, grounds_checked=grounds_checked,
|
||||
evidence=evidence, conditions=conditions, fold=fold)
|
||||
if canon_project_id == other_project_id:
|
||||
problems.append("a conflict is between two different projects")
|
||||
if not (reason or "").strip():
|
||||
problems.append("a resolution needs a reason")
|
||||
if problems:
|
||||
raise ValueError("; ".join(problems))
|
||||
if not await access.can_write_note(user_id, idea_id):
|
||||
raise ValueError(f"note {idea_id} not found or no write access")
|
||||
for pid in (canon_project_id, other_project_id):
|
||||
if not await access.can_write_project(user_id, pid):
|
||||
raise ValueError(f"project {pid} not found or no write access")
|
||||
named = await family_svc._existing_decision_ids(precedent_ids or [])
|
||||
consulted = await family_svc.precedents(user_id, idea_id)
|
||||
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
|
||||
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, idea_id)
|
||||
if idea is None or idea.status != "canon":
|
||||
raise ValueError(f"#{idea_id} is not family canon — only canon has conflicts")
|
||||
for pid in (canon_project_id, other_project_id):
|
||||
if await _row(session, pid, idea_id) is None and not await _is_member(session, pid, idea_id):
|
||||
raise ValueError(f"#{idea_id} does not reach project {pid}")
|
||||
titles = dict((await session.execute(
|
||||
select(Project.id, Project.title)
|
||||
.where(Project.id.in_([canon_project_id, other_project_id]))
|
||||
)).all())
|
||||
note = await session.get(Note, idea_id)
|
||||
owner, body = note.user_id, note.body or ""
|
||||
|
||||
# The losing side's reasoning goes into the canon FIRST: whatever fails
|
||||
# after this, the reasoning is not dropped.
|
||||
section = _fold_section(
|
||||
ground=ground, fold=fold, conditions=conditions,
|
||||
canon_title=titles[canon_project_id], other_title=titles[other_project_id],
|
||||
when=family_svc._now().date().isoformat(),
|
||||
)
|
||||
await notes_svc.update_note(owner, idea_id, body=f"{body.rstrip()}\n\n{section}\n")
|
||||
|
||||
split = ground == "split_by_condition"
|
||||
other_outcome = "adopted" if split else "owed"
|
||||
g = _GROUND[ground]
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, idea_id)
|
||||
before = await family_svc._snapshot(session, idea)
|
||||
idea.canon_version = await family_svc.next_version(session, idea)
|
||||
idea.updated_at = family_svc._now()
|
||||
await session.flush()
|
||||
revision = family_svc._log(
|
||||
session, idea_id=idea_id, action="revise", reason=reason, before=before,
|
||||
after=await family_svc._snapshot(session, idea),
|
||||
evidence={
|
||||
"conflict": {
|
||||
"ground": ground,
|
||||
"grounds_checked": {k: str((grounds_checked or {}).get(k) or "").strip()
|
||||
for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]},
|
||||
"canon": titles[canon_project_id],
|
||||
"other": titles[other_project_id],
|
||||
"folded_as": g["fold_as"],
|
||||
"conditions": conditions if split else None,
|
||||
},
|
||||
"evidence": evidence,
|
||||
},
|
||||
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.flush()
|
||||
row_ids = {}
|
||||
answers = (
|
||||
(canon_project_id, "adopted",
|
||||
f"Canon after a family conflict ({g['title'].lower()}): {reason.strip()}"),
|
||||
(other_project_id, other_outcome,
|
||||
(f"Canon where {conditions['other'].strip()} (split by condition): {reason.strip()}"
|
||||
if split else
|
||||
f"Lost a family conflict ({g['title'].lower()}) — adopt the canon: {reason.strip()}")),
|
||||
)
|
||||
for pid, outcome, why in answers:
|
||||
row = await _row_for_write(session, pid, idea_id)
|
||||
row_before = _row_state(row)
|
||||
_answer(row, status=outcome, reason=why, version=idea.canon_version,
|
||||
decided_via=decided_via)
|
||||
family_svc._log(
|
||||
session, idea_id=idea_id, project_id=pid, action="assess", reason=why,
|
||||
before=row_before, after=_row_state(row), evidence={"evidence": evidence},
|
||||
precedent_ids=[revision.id], decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
row_ids[pid] = row.id
|
||||
await session.commit()
|
||||
await session.refresh(revision)
|
||||
task = await _sync_owed_task(user_id, row_ids[other_project_id], reason=answers[1][2])
|
||||
await _sync_owed_task(user_id, row_ids[canon_project_id], reason=answers[0][2])
|
||||
return {
|
||||
"idea": idea.to_dict(),
|
||||
"decision": revision.to_dict(),
|
||||
"folded_as": g["fold_as"],
|
||||
"adoptions": await list_adoptions(user_id, idea_id=idea_id),
|
||||
"owed_task": task,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""The adoption ledger without a database (milestone 463 step 4).
|
||||
|
||||
The parts that decide are pure and pinned here: what an assessment needs
|
||||
before it can be recorded, what each ground of the conflict order needs —
|
||||
including that every ground ABOVE the deciding one was said not to apply —
|
||||
when an answer needs a recheck, how a losing side is folded into the note,
|
||||
and which reference an owed task points at. Beside them: the outcomes and
|
||||
the order the agent is told are the ones enforced. The state machine against
|
||||
Postgres is in tests/test_integration_family_adoption.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
||||
from scribe.services.family_adoption import (
|
||||
CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line,
|
||||
assessment_problems, conflict_problems, needs_recheck,
|
||||
)
|
||||
from tests.helpers import tool_doc
|
||||
|
||||
|
||||
# --- what an assessment needs ------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("outcome", OUTCOME_KEYS)
|
||||
def test_every_outcome_needs_a_reason(outcome):
|
||||
problems = assessment_problems(outcome=outcome, reason=" ", evidence=["src/x.py"])
|
||||
assert len(problems) == 1 and outcome in problems[0]
|
||||
|
||||
|
||||
def test_adopted_needs_evidence_naming_where():
|
||||
assert assessment_problems(outcome="adopted", reason="done", evidence=[]) == [
|
||||
"adopted needs evidence naming where the project does it"]
|
||||
assert assessment_problems(outcome="adopted", reason="done", evidence=[" "]) != []
|
||||
assert assessment_problems(outcome="adopted", reason="done", evidence=["ci run 12"]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ("exempt", "variant", "owed"))
|
||||
def test_the_other_outcomes_need_no_evidence(outcome):
|
||||
assert assessment_problems(outcome=outcome, reason="a fact", evidence=None) == []
|
||||
|
||||
|
||||
def test_an_unknown_outcome_is_refused_by_name():
|
||||
problems = assessment_problems(outcome="ignored", reason="x", evidence=[])
|
||||
assert problems and all(k in problems[0] for k in OUTCOME_KEYS)
|
||||
|
||||
|
||||
# --- the conflict order, branch by branch ----------------------------------------
|
||||
|
||||
def _checked(upto: str) -> dict:
|
||||
return {k: f"{k} did not decide" for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(upto)]}
|
||||
|
||||
|
||||
GOOD = {
|
||||
"operator_stance": dict(evidence=["rule: one keystore per app"]),
|
||||
"covers_failure": dict(evidence=["incident #12: update refused, signature mismatch"]),
|
||||
"split_by_condition": dict(conditions={"canon": "the app self-updates",
|
||||
"other": "a store installs it"}),
|
||||
"most_recent_complete": dict(evidence=["verified on a device 2026-10-01"]),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ground", CONFLICT_KEYS)
|
||||
def test_each_ground_holds_when_its_needs_are_met(ground):
|
||||
kw = dict(evidence=None, conditions=None, **{**GOOD[ground]})
|
||||
assert conflict_problems(ground=ground, grounds_checked=_checked(ground),
|
||||
fold="their reasoning", **kw) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ground", CONFLICT_KEYS[1:])
|
||||
def test_a_ground_is_refused_while_any_ground_above_it_is_unanswered(ground):
|
||||
above = CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
|
||||
for skipped in above:
|
||||
checked = {k: v for k, v in _checked(ground).items() if k != skipped}
|
||||
problems = conflict_problems(ground=ground, grounds_checked=checked,
|
||||
fold="x", **{"evidence": None, "conditions": None,
|
||||
**GOOD[ground]})
|
||||
assert len(problems) == 1 and f"'{skipped}'" in problems[0]
|
||||
|
||||
|
||||
def test_the_first_ground_needs_nothing_checked_above_it():
|
||||
assert conflict_problems(ground="operator_stance", grounds_checked=None, fold="x",
|
||||
evidence=["rule 4"], conditions=None) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ground", ("operator_stance", "covers_failure", "most_recent_complete"))
|
||||
def test_grounds_one_two_and_four_need_their_evidence(ground):
|
||||
problems = conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="x",
|
||||
evidence=[" "], conditions=None)
|
||||
assert len(problems) == 1 and "evidence" in problems[0]
|
||||
|
||||
|
||||
def test_a_split_needs_both_conditions():
|
||||
for conds in (None, {"canon": "self-updates"}, {"canon": "", "other": "store"}):
|
||||
problems = conflict_problems(ground="split_by_condition",
|
||||
grounds_checked=_checked("split_by_condition"),
|
||||
fold="x", evidence=None, conditions=conds)
|
||||
assert len(problems) == 1 and "conditions" in problems[0]
|
||||
|
||||
|
||||
def test_the_losing_reasoning_is_required():
|
||||
problems = conflict_problems(ground="operator_stance", grounds_checked=None, fold=" ",
|
||||
evidence=["rule 4"], conditions=None)
|
||||
assert len(problems) == 1 and "never dropped" in problems[0]
|
||||
|
||||
|
||||
def test_an_unknown_ground_names_the_order():
|
||||
problems = conflict_problems(ground="seniority", grounds_checked=None, fold="x",
|
||||
evidence=[], conditions=None)
|
||||
assert problems == [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
|
||||
|
||||
|
||||
# --- recheck -------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("row_status,row_version,idea_status,idea_version,expected", [
|
||||
("adopted", 1, "canon", 2, True),
|
||||
("owed", 1, "canon", 2, True),
|
||||
("adopted", 2, "canon", 2, False),
|
||||
# An undone revision leaves answers AHEAD of the idea: still not agreeing.
|
||||
("adopted", 3, "canon", 2, True),
|
||||
("unassessed", None, "canon", 2, False),
|
||||
("adopted", 1, "retired", 2, False),
|
||||
])
|
||||
def test_needs_recheck(row_status, row_version, idea_status, idea_version, expected):
|
||||
assert needs_recheck(row_status, row_version, idea_status, idea_version) is expected
|
||||
|
||||
|
||||
# --- folding the losing side into the note ------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("ground,heading", [
|
||||
("operator_stance", "### Alternative — two's approach"),
|
||||
("covers_failure", "### Trap — two's approach"),
|
||||
("most_recent_complete", "### Alternative — two's approach"),
|
||||
("split_by_condition", "### When a store installs it — two's approach"),
|
||||
])
|
||||
def test_the_losing_side_is_folded_as_its_ground_says(ground, heading):
|
||||
text = _fold_section(ground=ground, fold="Their reasoning.",
|
||||
conditions=GOOD["split_by_condition"]["conditions"],
|
||||
canon_title="one", other_title="two", when="2026-10-06")
|
||||
assert text.startswith(heading)
|
||||
assert "Their reasoning." in text and "one's approach" in text
|
||||
|
||||
|
||||
# --- the reference an owed task points at -----------------------------------------
|
||||
|
||||
REFS = [
|
||||
{"id": 10, "title": "Update installer", "language": "kotlin"},
|
||||
{"id": 11, "title": "Update installer", "language": "dart"},
|
||||
]
|
||||
|
||||
|
||||
def test_the_reference_in_the_projects_language_is_named():
|
||||
line = _reference_line(REFS, ["dart"], idea_id=5)
|
||||
assert "#11" in line and "#10" not in line
|
||||
|
||||
|
||||
def test_no_reference_in_the_language_names_the_ones_that_exist():
|
||||
line = _reference_line(REFS, ["python"], idea_id=5)
|
||||
assert "python" in line and "#10" in line and "#11" in line
|
||||
|
||||
|
||||
def test_no_reference_at_all_points_at_the_idea():
|
||||
assert "#5" in _reference_line([], ["python"], idea_id=5)
|
||||
|
||||
|
||||
# --- the product text and the doors ---------------------------------------------------
|
||||
|
||||
def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
|
||||
"""Rule 119: the outcomes and the order are product text. The tool
|
||||
docstrings must name every outcome and every ground the service checks,
|
||||
in the service's order."""
|
||||
assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
|
||||
for n, key in enumerate(OUTCOME_KEYS, start=1):
|
||||
assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}"
|
||||
resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict")
|
||||
for n, key in enumerate(CONFLICT_KEYS, start=1):
|
||||
assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}"
|
||||
assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"]
|
||||
|
||||
|
||||
def test_every_adoption_tool_is_classified():
|
||||
reads = {"get_family_adoption", "list_family_adoptions"}
|
||||
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
|
||||
"set_family_references"}
|
||||
assert reads <= _READ_ONLY_TOOLS
|
||||
assert writes <= _WRITE_TOOLS
|
||||
@@ -0,0 +1,389 @@
|
||||
"""The adoption ledger against real Postgres (milestone 463 step 4).
|
||||
|
||||
What the unit lane can't show: an answer moves the row and logs exactly one
|
||||
decision; an owed answer files its task in the OWING project, tagged to the
|
||||
System matching the idea's, and the task follows the answer from there; a
|
||||
version bump leaves every older answer reading as needing a recheck; each
|
||||
branch of the conflict order folds the losing side into the note and settles
|
||||
both rows; and the same assessment given twice records nothing the second
|
||||
time.
|
||||
|
||||
Precedent search ranks by meaning and the lane has no embedding model, so
|
||||
the search is stubbed to "nothing similar" (`_no_meaning`). The same-idea
|
||||
precedents — another project's answer to this idea — need no embedder, and
|
||||
are tested here.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.system import RecordSystem, System
|
||||
from scribe.models.task_log import TaskLog
|
||||
from scribe.models.user import User
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import family_adoption as adoption_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER = "family_adoption_owner"
|
||||
OUTSIDER = "family_adoption_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 promotion 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():
|
||||
"""Two Android apps and a Go service. The idea is a note in the first app,
|
||||
filed under a System mapped to a canonical area; the second app has its
|
||||
own System mapped to the same area, and one that is not. Promoted, so
|
||||
both apps hold an `unassessed` row."""
|
||||
android, go = await _platform_id("android-app"), await _platform_id("go")
|
||||
async with async_session() as s:
|
||||
canonical = await s.scalar(select(CanonicalSystem.id).where(
|
||||
CanonicalSystem.deleted_at.is_(None)).order_by(CanonicalSystem.id).limit(1))
|
||||
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")
|
||||
s.add_all([a, b, c])
|
||||
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="declared"),
|
||||
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
||||
])
|
||||
a_sys = System(user_id=owner.id, project_id=a.id, name="Release", canonical_id=canonical)
|
||||
b_sys = System(user_id=owner.id, project_id=b.id, name="Shipping", canonical_id=canonical)
|
||||
b_other = System(user_id=owner.id, project_id=b.id, name="Unrelated")
|
||||
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
|
||||
body="One keystore, two channels, in-place update.")
|
||||
s.add_all([a_sys, b_sys, b_other, pattern])
|
||||
await s.flush()
|
||||
s.add(RecordSystem(note_id=pattern.id, system_id=a_sys.id))
|
||||
await s.commit()
|
||||
ids = {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id, "c": c.id,
|
||||
"note": pattern.id, "b_sys": b_sys.id, "b_other": b_other.id}
|
||||
out = await family_svc.promote(
|
||||
ids["owner"], ids["note"], applies_when="any Android app that ships its own APK",
|
||||
platforms=["android-app"], criteria=CRITERIA,
|
||||
evidence=["CI green on the release lane"], reason="proven, stated for the platform",
|
||||
)
|
||||
assert out["promoted"] is True
|
||||
return ids
|
||||
|
||||
|
||||
async def _assess(f, project: str, outcome: str, reason: str = "the gap", **kw):
|
||||
if outcome == "adopted":
|
||||
kw.setdefault("evidence", ["app/build.gradle: the release lane"])
|
||||
return await adoption_svc.assess(f["owner"], f[project], f["note"],
|
||||
outcome=outcome, reason=reason, **kw)
|
||||
|
||||
|
||||
async def _row(f, project: str) -> FamilyAdoption:
|
||||
async with async_session() as s:
|
||||
return (await s.execute(select(FamilyAdoption).where(
|
||||
FamilyAdoption.project_id == f[project], FamilyAdoption.idea_id == f["note"]))).scalars().one()
|
||||
|
||||
|
||||
async def _task(task_id: int) -> Note:
|
||||
async with async_session() as s:
|
||||
return await s.get(Note, task_id)
|
||||
|
||||
|
||||
async def _decisions(f, project: str) -> list[FamilyDecision]:
|
||||
async with async_session() as s:
|
||||
return list((await s.execute(select(FamilyDecision).where(
|
||||
FamilyDecision.idea_id == f["note"], FamilyDecision.project_id == f[project])
|
||||
.order_by(FamilyDecision.id))).scalars().all())
|
||||
|
||||
|
||||
# --- each state change ----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("outcome", ("adopted", "variant", "exempt"))
|
||||
async def test_an_answer_moves_the_row_and_logs_one_decision(family, outcome):
|
||||
out = await _assess(family, "b", outcome, reason="a fact about android two")
|
||||
row = await _row(family, "b")
|
||||
assert (row.status, row.reason, row.canon_version, row.decided_via) == (
|
||||
outcome, "a fact about android two", 1, "agent")
|
||||
assert row.assessed_at is not None and row.owed_task_id is None
|
||||
[decision] = await _decisions(family, "b")
|
||||
assert decision.action == "assess"
|
||||
assert decision.before == {"status": "unassessed", "reason": "", "canon_version": None}
|
||||
assert decision.after["status"] == outcome
|
||||
assert out["changed"] is True and out["owed_task"] is None
|
||||
|
||||
|
||||
async def test_owed_files_a_task_in_the_owing_project_tagged_to_the_matching_system(family):
|
||||
out = await _assess(family, "b", "owed", reason="no in-place update yet")
|
||||
row = await _row(family, "b")
|
||||
assert row.status == "owed" and row.owed_task_id == out["owed_task"]["id"]
|
||||
task = await _task(row.owed_task_id)
|
||||
# Filed where the work is owed — never in the idea's own project.
|
||||
assert task.project_id == family["b"] and task.status == "todo"
|
||||
assert f"#{family['note']}" in task.body and "no in-place update yet" in task.body
|
||||
assert "any Android app that ships its own APK" in task.body
|
||||
async with async_session() as s:
|
||||
tagged = set((await s.execute(select(RecordSystem.system_id).where(
|
||||
RecordSystem.note_id == task.id))).scalars().all())
|
||||
assert tagged == {family["b_sys"]}
|
||||
|
||||
|
||||
async def test_the_owed_task_follows_the_answer(family):
|
||||
await _assess(family, "b", "owed", reason="not built")
|
||||
task_id = (await _row(family, "b")).owed_task_id
|
||||
|
||||
await _assess(family, "b", "variant", reason="installs through a managed store")
|
||||
assert (await _task(task_id)).status == "cancelled"
|
||||
|
||||
out = await _assess(family, "b", "owed", reason="the store plan fell through")
|
||||
assert out["owed_task"] == {"id": task_id, "title": (await _task(task_id)).title,
|
||||
"status": "todo", "action": "reopened"}
|
||||
|
||||
await _assess(family, "b", "adopted", reason="built")
|
||||
assert (await _task(task_id)).status == "done"
|
||||
async with async_session() as s:
|
||||
logs = (await s.execute(select(TaskLog.content).where(TaskLog.task_id == task_id)
|
||||
.order_by(TaskLog.id))).scalars().all()
|
||||
assert [("variant" in logs[0]), ("owed again" in logs[1]), ("adopted" in logs[2])] == [True] * 3
|
||||
# One task across the whole life of the answer.
|
||||
async with async_session() as s:
|
||||
filed = (await s.execute(select(Note.id).where(
|
||||
Note.project_id == family["b"], Note.title.like("Adopt the family idea%")))).scalars().all()
|
||||
assert filed == [task_id]
|
||||
|
||||
|
||||
async def test_the_same_assessment_twice_records_nothing_the_second_time(family):
|
||||
first = await _assess(family, "b", "owed", reason="not built")
|
||||
second = await _assess(family, "b", "owed", reason="not built")
|
||||
assert first["changed"] is True and second["changed"] is False
|
||||
assert second["decision"] is None
|
||||
assert second["owed_task"]["id"] == first["owed_task"]["id"]
|
||||
assert len(await _decisions(family, "b")) == 1
|
||||
|
||||
|
||||
async def test_a_departure_without_a_reason_writes_nothing(family):
|
||||
with pytest.raises(ValueError, match="exempt needs a reason"):
|
||||
await _assess(family, "b", "exempt", reason=" ")
|
||||
with pytest.raises(ValueError, match="adopted needs evidence"):
|
||||
await _assess(family, "b", "adopted", evidence=[])
|
||||
assert (await _row(family, "b")).status == "unassessed"
|
||||
assert await _decisions(family, "b") == []
|
||||
|
||||
|
||||
async def test_an_idea_that_does_not_reach_the_project_is_refused(family):
|
||||
with pytest.raises(ValueError, match="not on any of"):
|
||||
await _assess(family, "c", "exempt", reason="it is a Go service")
|
||||
|
||||
|
||||
async def test_only_canon_is_assessed(family):
|
||||
await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||
with pytest.raises(ValueError, match="not family canon"):
|
||||
await _assess(family, "a", "adopted")
|
||||
|
||||
|
||||
async def test_someone_who_cannot_write_the_project_cannot_answer_for_it(family):
|
||||
with pytest.raises(ValueError, match="no write access"):
|
||||
await adoption_svc.assess(family["outsider"], family["b"], family["note"],
|
||||
outcome="exempt", reason="not mine to say")
|
||||
|
||||
|
||||
async def test_another_projects_answer_is_recorded_as_precedent(family):
|
||||
first = await _assess(family, "a", "adopted", reason="the source app")
|
||||
second = await _assess(family, "b", "owed", reason="not built")
|
||||
assert first["decision"]["id"] in second["decision"]["precedent_ids"]
|
||||
assert second["precedents"][0]["relation"] == "same idea, another project"
|
||||
assert second["precedents"][0]["project_title"] == "android one"
|
||||
|
||||
|
||||
# --- recheck --------------------------------------------------------------------------
|
||||
|
||||
async def test_a_revision_leaves_every_older_answer_needing_a_recheck(family):
|
||||
await _assess(family, "a", "adopted")
|
||||
await _assess(family, "b", "exempt", reason="ships through a store")
|
||||
out = await family_svc.revise(family["owner"], family["note"],
|
||||
reason="the keystore now rotates", evidence=["incident"])
|
||||
assert out["idea"]["canon_version"] == 2
|
||||
rows = await adoption_svc.list_adoptions(family["owner"], idea_id=family["note"])
|
||||
assert {r["project_title"]: r["needs_recheck"] for r in rows} == {
|
||||
"android one": True, "android two": True}
|
||||
assert [r["project_title"] for r in await adoption_svc.list_adoptions(
|
||||
family["owner"], idea_id=family["note"], recheck_only=True)] == ["android one", "android two"]
|
||||
|
||||
await _assess(family, "a", "adopted", reason="rotation added")
|
||||
rows = {r["project_title"]: r for r in await adoption_svc.list_adoptions(
|
||||
family["owner"], idea_id=family["note"])}
|
||||
assert rows["android one"]["needs_recheck"] is False
|
||||
assert rows["android one"]["canon_version"] == 2
|
||||
assert rows["android two"]["needs_recheck"] is True
|
||||
|
||||
|
||||
async def test_a_revision_needs_canon_and_a_reason(family):
|
||||
with pytest.raises(ValueError, match="needs a reason"):
|
||||
await family_svc.revise(family["owner"], family["note"], reason="")
|
||||
with pytest.raises(ValueError, match="at least one platform"):
|
||||
await family_svc.revise(family["owner"], family["note"], reason="x", platforms=[])
|
||||
|
||||
|
||||
# --- undo ------------------------------------------------------------------------------
|
||||
|
||||
async def test_undoing_an_owed_answer_restores_the_row_and_closes_its_task(family):
|
||||
out = await _assess(family, "b", "owed", reason="not built")
|
||||
task_id = out["owed_task"]["id"]
|
||||
decisions = await family_svc.list_decisions(family["owner"], project_id=family["b"])
|
||||
assert decisions[0]["undoable"] is True and decisions[0]["project_title"] == "android two"
|
||||
|
||||
await family_svc.undo(family["owner"], out["decision"]["id"], reason="assessed too early")
|
||||
row = await _row(family, "b")
|
||||
assert (row.status, row.reason, row.canon_version, row.assessed_at) == (
|
||||
"unassessed", None, None, None)
|
||||
assert (await _task(task_id)).status == "cancelled"
|
||||
undo_row = (await _decisions(family, "b"))[-1]
|
||||
assert undo_row.action == "undo" and undo_row.precedent_ids == [out["decision"]["id"]]
|
||||
|
||||
|
||||
async def test_only_the_latest_answer_is_undoable(family):
|
||||
first = await _assess(family, "b", "owed", reason="not built")
|
||||
await _assess(family, "b", "adopted", reason="built")
|
||||
with pytest.raises(ValueError, match="came after it"):
|
||||
await family_svc.undo(family["owner"], first["decision"]["id"], reason="x")
|
||||
|
||||
|
||||
# --- the conflict order -------------------------------------------------------------
|
||||
|
||||
def _checked(ground: str) -> dict:
|
||||
keys = adoption_svc.CONFLICT_KEYS
|
||||
return {k: f"{k} does not apply here" for k in keys[:keys.index(ground)]}
|
||||
|
||||
|
||||
async def _resolve(f, ground: str, **kw):
|
||||
kw.setdefault("evidence", ["named in the record"])
|
||||
return await adoption_svc.resolve_conflict(
|
||||
f["owner"], f["note"], canon_project_id=f["a"], other_project_id=f["b"],
|
||||
ground=ground, grounds_checked=_checked(ground),
|
||||
fold="Android two signs in CI with a throwaway key.", reason="settled", **kw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ground,heading,other_outcome", [
|
||||
("operator_stance", "### Alternative — android two's approach", "owed"),
|
||||
("covers_failure", "### Trap — android two's approach", "owed"),
|
||||
("most_recent_complete", "### Alternative — android two's approach", "owed"),
|
||||
])
|
||||
async def test_a_side_that_loses_is_folded_in_and_owes_the_canon(family, ground, heading, other_outcome):
|
||||
out = await _resolve(family, ground)
|
||||
async with async_session() as s:
|
||||
body = (await s.get(Note, family["note"])).body
|
||||
idea = await s.get(FamilyIdea, family["note"])
|
||||
assert heading in body and "throwaway key" in body
|
||||
assert idea.canon_version == 2
|
||||
assert out["decision"]["action"] == "revise"
|
||||
assert out["decision"]["evidence"]["conflict"]["ground"] == ground
|
||||
a, b = await _row(family, "a"), await _row(family, "b")
|
||||
assert (a.status, a.canon_version) == ("adopted", 2)
|
||||
assert (b.status, b.canon_version) == (other_outcome, 2)
|
||||
assert (await _task(b.owed_task_id)).project_id == family["b"]
|
||||
# Each answer names the revision as the decision it followed.
|
||||
assert (await _decisions(family, "b"))[-1].precedent_ids == [out["decision"]["id"]]
|
||||
|
||||
|
||||
async def test_a_split_makes_each_side_canon_under_its_condition(family):
|
||||
out = await _resolve(family, "split_by_condition", evidence=None,
|
||||
conditions={"canon": "the app updates itself",
|
||||
"other": "a managed store installs it"})
|
||||
async with async_session() as s:
|
||||
body = (await s.get(Note, family["note"])).body
|
||||
assert "### When a managed store installs it — android two's approach" in body
|
||||
assert "When the app updates itself, android one's approach above is the canon" in body
|
||||
a, b = await _row(family, "a"), await _row(family, "b")
|
||||
assert (a.status, b.status) == ("adopted", "adopted")
|
||||
assert b.owed_task_id is None and out["owed_task"] is None
|
||||
|
||||
|
||||
async def test_a_ground_cannot_skip_the_order_and_nothing_is_written(family):
|
||||
async with async_session() as s:
|
||||
body_before = (await s.get(Note, family["note"])).body
|
||||
with pytest.raises(ValueError, match="'operator_stance' comes before 'covers_failure'"):
|
||||
await adoption_svc.resolve_conflict(
|
||||
family["owner"], family["note"], canon_project_id=family["a"],
|
||||
other_project_id=family["b"], ground="covers_failure", grounds_checked={},
|
||||
fold="x", evidence=["incident"], reason="settled")
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, family["note"])).body == body_before
|
||||
assert (await s.get(FamilyIdea, family["note"])).canon_version == 1
|
||||
|
||||
|
||||
# --- the matrix -----------------------------------------------------------------------
|
||||
|
||||
async def test_the_matrix_shows_every_member_and_who_has_not_been_asked(family):
|
||||
android = await _platform_id("android-app")
|
||||
async with async_session() as s:
|
||||
late = Project(user_id=family["owner"], title="android three")
|
||||
s.add(late)
|
||||
await s.flush()
|
||||
s.add(ProjectPlatform(project_id=late.id, platform_id=android, state="detected"))
|
||||
await s.commit()
|
||||
late_id = late.id
|
||||
await _assess(family, "b", "owed", reason="not built")
|
||||
|
||||
matrix = await adoption_svc.adoption_matrix(family["owner"])
|
||||
cells = {(c["project_title"]): c for c in matrix["cells"] if c["idea_id"] == family["note"]}
|
||||
assert set(cells) == {"android one", "android two", "android three"}
|
||||
assert cells["android two"]["owed_task"]["status"] == "todo"
|
||||
assert cells["android three"]["reached"] is False and cells["android three"]["status"] == "unassessed"
|
||||
assert [o["key"] for o in matrix["outcomes"]] == list(adoption_svc.OUTCOME_KEYS)
|
||||
|
||||
# A late member's first answer creates its row.
|
||||
await adoption_svc.assess(family["owner"], late_id, family["note"],
|
||||
outcome="exempt", reason="ships through a store")
|
||||
one = await adoption_svc.adoption_matrix(family["owner"], project_id=late_id)
|
||||
assert [c["status"] for c in one["cells"]] == ["exempt"]
|
||||
|
||||
# Filtered to a platform no canon idea is for, there is nothing to show.
|
||||
assert (await adoption_svc.adoption_matrix(family["owner"], platform="go"))["ideas"] == []
|
||||
|
||||
|
||||
async def test_the_matrix_hides_what_the_caller_cannot_read(family):
|
||||
matrix = await adoption_svc.adoption_matrix(family["outsider"])
|
||||
assert all(i["note_id"] != family["note"] for i in matrix["ideas"])
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Structural tests for the family blueprint (milestone 463 step 3) — every
|
||||
"""Structural tests for the family blueprint (milestone 463 steps 3 and 4) — 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."""
|
||||
@@ -18,12 +18,20 @@ def test_family_blueprint_routes_every_endpoint():
|
||||
("/api/family/ideas/<int:note_id>/retire", "POST"),
|
||||
("/api/family/decisions", "GET"),
|
||||
("/api/family/decisions/<int:decision_id>/undo", "POST"),
|
||||
("/api/family/matrix", "GET"),
|
||||
):
|
||||
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"):
|
||||
for name in ("propose", "promote", "retire", "undo", "revise"):
|
||||
params = inspect.signature(getattr(svc, name)).parameters
|
||||
assert "user_id" in params and "decided_via" in params, name
|
||||
|
||||
|
||||
def test_every_ledger_write_takes_the_caller_and_who_decided():
|
||||
from scribe.services import family_adoption as svc
|
||||
for name in ("assess", "resolve_conflict", "undo_assessment"):
|
||||
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