Merge pull request 'feat(learning): שער-אישור ל-decision_lessons — רק לקח מאושר זורם לכותב (INV-LRN1, #126)' (#202) from worktree-lesson-approval-gate into main
This commit was merged in pull request #202.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
10
web/app.py
10
web/app.py
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user