feat(learning): שער-אישור ל-decision_lessons — רק לקח מאושר זורם לכותב (INV-LRN1, #126)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 12s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 12s
אודיט #122 חשף שלקחי-הפאנל (decision_lessons) זרמו לכותב אוטומטית (block_writer → get_recent_decision_lessons) ללא סינון-אישור — הפאנל כתב, והכותב צרך מיד, בעקיפת שער-היו"ר (INV-LRN1/G10). מנגד, מה שהיו"ר אישר ב-promote הלך לערוץ נפרד (appeal_type_rules). תוצאה: דליפה — תוכן לא-מאושר השפיע על הכתיבה. התיקון — שער-אישור מפורש: - עמודת review_status (proposed|approved|rejected) ל-decision_lessons (SCHEMA_V34). - get_recent_decision_lessons (צרכן-הכותב) מחזיר רק review_status='approved'. - הפאנל (style_lesson_panel) כותב 'proposed' (ברירת-מחדל) → לא זורם עד אישור. - לקח שהיו"ר מקליד ידנית ב-/training = 'approved' מיידית (מדלג על שער-ההצעה). - UI (lessons-tab, טאב "קורפוס" ב-/training): תג-סטטוס + כפתורי אשר/דחה/בטל-אישור. הכרעת-יו"ר (2026-06-11): כל הלקחים שקדמו לשער (41) מתאפסים ל-'proposed' — שום לקח לא זורם עד אישור מפורש (ברירת-המחדל של העמודה מיישמת זאת על הקיימים). Invariants: - INV-LRN1 / G10 (מקיים) — עדכון-ידע לערוץ-הכותב דורש אישור-יו"ר מפורש; אין auto-commit. - INV-LRN5 (נשמר) — substance ממילא מסונן בפאנל; השער הוא על style_method בלבד. - G1 (מקיים) — סינון-במקור (get_recent) ולא תיקון-בקריאה אצל הכותב. - G2 (מקיים) — אותו פנקס decision_lessons; אין מסלול מקביל. api:types: להריץ npm run api:types אחרי deploy (review_status נוסף ל-payload; הטיפוסים הידניים ב-training.ts כבר מעודכנים, tsc עובר). ref: #122 · #126 · data/audit/learning-loop-activity-20260611.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user