Merge pull request 'feat(learning): שער-אישור ל-decision_lessons — רק לקח מאושר זורם לכותב (INV-LRN1, #126)' (#202) from worktree-lesson-approval-gate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m14s
G12 Leak-Guard / leak-guard (push) Successful in 6s

This commit was merged in pull request #202.
This commit is contained in:
2026-06-11 18:14:38 +00:00
4 changed files with 110 additions and 9 deletions

View File

@@ -1442,6 +1442,19 @@ CREATE TABLE IF NOT EXISTS drain_controls (
);
"""
SCHEMA_V34_SQL = """
-- decision_lessons review gate (INV-LRN1/G10): a lesson must be chair-APPROVED
-- before it flows to the writer. The style panel writes proposals
-- (review_status='proposed'); get_recent_decision_lessons (writer-consumed) returns
-- only 'approved'. The chair approves/rejects in /training. Existing rows take the
-- column default — per chair decision (2026-06-11) all pre-gate lessons reset to
-- 'proposed' (nothing flows until re-approved).
ALTER TABLE decision_lessons
ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'proposed';
CREATE INDEX IF NOT EXISTS idx_decision_lessons_review
ON decision_lessons(review_status);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
@@ -1479,6 +1492,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V31_SQL)
await conn.execute(SCHEMA_V32_SQL)
await conn.execute(SCHEMA_V33_SQL)
await conn.execute(SCHEMA_V34_SQL)
logger.info("Database schema initialized (v1-v33)")
@@ -2257,7 +2271,7 @@ async def list_decision_lessons(corpus_id: UUID) -> list[dict]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, style_corpus_id, lesson_text, category, source, "
" applied_to_skill, created_by, created_at, updated_at "
" applied_to_skill, review_status, created_by, created_at, updated_at "
"FROM decision_lessons WHERE style_corpus_id = $1 "
"ORDER BY created_at DESC",
corpus_id,
@@ -2272,16 +2286,20 @@ async def add_decision_lesson(
category: str = "general",
source: str = "manual",
created_by: str = "chaim",
review_status: str = "proposed",
) -> dict:
# review_status gate (INV-LRN1/G10): panel-written lessons default to 'proposed'
# and do NOT flow to the writer until the chair approves. A lesson the chair
# authors directly is passed review_status='approved' by the caller.
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO decision_lessons "
"(style_corpus_id, lesson_text, category, source, created_by) "
"VALUES ($1, $2, $3, $4, $5) "
"(style_corpus_id, lesson_text, category, source, created_by, review_status) "
"VALUES ($1, $2, $3, $4, $5, $6) "
"RETURNING id, style_corpus_id, lesson_text, category, source, "
" applied_to_skill, created_by, created_at, updated_at",
corpus_id, lesson_text, category, source, created_by,
" applied_to_skill, review_status, created_by, created_at, updated_at",
corpus_id, lesson_text, category, source, created_by, review_status,
)
return dict(row) if row else {}
@@ -2292,6 +2310,7 @@ async def update_decision_lesson(
lesson_text: str | None = None,
category: str | None = None,
applied_to_skill: bool | None = None,
review_status: str | None = None,
) -> dict:
sets: dict = {}
if lesson_text is not None:
@@ -2300,6 +2319,8 @@ async def update_decision_lesson(
sets["category"] = category
if applied_to_skill is not None:
sets["applied_to_skill"] = applied_to_skill
if review_status is not None:
sets["review_status"] = review_status
if not sets:
return {"updated": False, "reason": "nothing to update"}
sets["updated_at"] = "now()" # sentinel — replaced inline below
@@ -2312,7 +2333,7 @@ async def update_decision_lesson(
row = await conn.fetchrow(
f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 "
f"RETURNING id, style_corpus_id, lesson_text, category, source, "
f" applied_to_skill, updated_at",
f" applied_to_skill, review_status, updated_at",
lesson_id, *values,
)
if not row:
@@ -2494,7 +2515,12 @@ async def get_methodology_overrides(category: str) -> dict:
async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") -> list[dict]:
"""Per-decision learnings the chair/curator attached in /training (decision_lessons),
so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first."""
so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.
Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved').
Panel-written proposals stay out of the writer's context until the chair approves
them in /training — the chair's review is the gate, not the panel's 2/2 vote.
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
@@ -2502,7 +2528,8 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
sc.decision_number, sc.practice_area
FROM decision_lessons dl
JOIN style_corpus sc ON sc.id = dl.style_corpus_id
WHERE ($2 = '' OR sc.practice_area = $2)
WHERE dl.review_status = 'approved'
AND ($2 = '' OR sc.practice_area = $2)
ORDER BY dl.created_at DESC
LIMIT $1""",
limit, practice_area,

View File

@@ -18,7 +18,9 @@
*/
import { useState } from "react";
import { Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles } from "lucide-react";
import {
Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles, Check, X, Undo2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -56,6 +58,13 @@ const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
const SOURCE_BADGE_FALLBACK = { label: "פאנל", cls: "bg-rule-soft text-ink-soft" };
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
const REVIEW_BADGE: Record<string, { label: string; cls: string }> = {
proposed: { label: "ממתין לאישור", cls: "bg-gold-wash text-gold-deep border-gold/40" },
approved: { label: "מאושר · זורם לכותב", cls: "bg-success-bg text-success border-success/40" },
rejected: { label: "נדחה", cls: "bg-rule-soft text-ink-muted line-through" },
};
export function LessonsTab({ corpusId }: { corpusId: string }) {
const { data, isPending } = useCorpusLessons(corpusId);
const add = useAddLesson(corpusId);
@@ -153,8 +162,22 @@ function LessonItem({
const del = useDeleteLesson(corpusId);
const sourceBadge = SOURCE_BADGE[lesson.source] ?? SOURCE_BADGE_FALLBACK;
const reviewBadge = REVIEW_BADGE[lesson.review_status] ?? REVIEW_BADGE.proposed;
const dirty = text !== lesson.lesson_text || category !== lesson.category;
const onSetReview = async (review_status: DecisionLesson["review_status"]) => {
try {
await patch.mutateAsync({ id: lesson.id, patch: { review_status } });
toast.success(
review_status === "approved" ? "אושר — הלקח יזרום לכותב"
: review_status === "rejected" ? "נדחה — לא יזרום לכותב"
: "הוחזר להמתנה",
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בעדכון");
}
};
const onSave = async () => {
try {
await patch.mutateAsync({
@@ -200,6 +223,9 @@ function LessonItem({
<Badge variant="outline" className={sourceBadge.cls}>
{sourceBadge.label}
</Badge>
<Badge variant="outline" className={reviewBadge.cls}>
{reviewBadge.label}
</Badge>
{lesson.applied_to_skill && (
<Badge variant="outline"
className="bg-success-bg text-success border-success/40">
@@ -249,6 +275,38 @@ function LessonItem({
{lesson.lesson_text}
</p>
<div className="flex items-center gap-2">
{/* review gate — only an approved lesson flows to the writer */}
{lesson.review_status === "approved" ? (
<Button variant="ghost" size="sm" onClick={() => onSetReview("proposed")}
disabled={patch.isPending}
className="text-ink-muted hover:text-ink">
<Undo2 className="w-3 h-3 me-1" />
בטל אישור
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={() => onSetReview("approved")}
disabled={patch.isPending}
className="text-success hover:text-success hover:bg-success-bg">
<Check className="w-3 h-3 me-1" />
אשר
</Button>
{lesson.review_status === "rejected" ? (
<Button variant="ghost" size="sm" onClick={() => onSetReview("proposed")}
disabled={patch.isPending} className="text-ink-muted hover:text-ink">
<Undo2 className="w-3 h-3 me-1" />
שחזר
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => onSetReview("rejected")}
disabled={patch.isPending}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3 h-3 me-1" />
דחה
</Button>
)}
</>
)}
<Button variant="ghost" size="sm" onClick={onToggleApplied}
disabled={patch.isPending}>
<Sparkles className="w-3 h-3 me-1" />

View File

@@ -473,11 +473,16 @@ export type DecisionLesson = {
// to future panel sources without a type break.
source: "manual" | "curator" | "chair" | "style_analyzer" | (string & {});
applied_to_skill: boolean;
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
// Panel-written lessons start as "proposed" and wait for the chair here.
review_status: "proposed" | "approved" | "rejected";
created_by: string;
created_at: string;
updated_at: string;
};
export type LessonReviewStatus = DecisionLesson["review_status"];
export type LessonCreate = {
lesson_text: string;
category?: DecisionLesson["category"];
@@ -488,6 +493,7 @@ export type LessonPatch = {
lesson_text?: string;
category?: DecisionLesson["category"];
applied_to_skill?: boolean;
review_status?: DecisionLesson["review_status"];
};
export const lessonsKeys = {

View File

@@ -1421,10 +1421,12 @@ class LessonPatch(BaseModel):
lesson_text: str | None = None
category: str | None = None
applied_to_skill: bool | None = None
review_status: str | None = None # proposed | approved | rejected (INV-LRN1 gate)
_LESSON_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
_LESSON_SOURCES = {"manual", "curator", "chair", "style_analyzer"}
_LESSON_REVIEW_STATUSES = {"proposed", "approved", "rejected"}
def _lesson_to_json(row: dict) -> dict:
@@ -1435,6 +1437,8 @@ def _lesson_to_json(row: dict) -> dict:
"category": row["category"],
"source": row["source"],
"applied_to_skill": bool(row["applied_to_skill"]),
# review gate (INV-LRN1/G10): only 'approved' flows to the writer.
"review_status": row.get("review_status", "proposed"),
"created_by": row.get("created_by", ""),
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else "",
@@ -1464,8 +1468,11 @@ async def add_corpus_lesson(corpus_id: str, body: LessonCreate):
raise HTTPException(400, f"invalid category; allowed: {sorted(_LESSON_CATEGORIES)}")
if body.source not in _LESSON_SOURCES:
raise HTTPException(400, f"invalid source; allowed: {sorted(_LESSON_SOURCES)}")
# A lesson the chair authors directly here is approved by construction — it skips
# the proposal gate (which exists to vet machine-generated panel lessons).
row = await db.add_decision_lesson(
cid, lesson_text=text, category=body.category, source=body.source,
review_status="approved",
)
if not row:
raise HTTPException(500, "failed to insert lesson")
@@ -1480,11 +1487,14 @@ async def patch_corpus_lesson(lesson_id: str, body: LessonPatch):
raise HTTPException(400, "invalid lesson_id")
if body.category is not None and body.category not in _LESSON_CATEGORIES:
raise HTTPException(400, f"invalid category; allowed: {sorted(_LESSON_CATEGORIES)}")
if body.review_status is not None and body.review_status not in _LESSON_REVIEW_STATUSES:
raise HTTPException(400, f"invalid review_status; allowed: {sorted(_LESSON_REVIEW_STATUSES)}")
result = await db.update_decision_lesson(
lid,
lesson_text=body.lesson_text,
category=body.category,
applied_to_skill=body.applied_to_skill,
review_status=body.review_status,
)
if not result.get("updated"):
if result.get("reason") == "not found":