feat(style-acq T14): שער-יו"ר לאישור הצעות-curator → הטמעה לפרופיל
סוגר את הלולאה מקצה-לקצה (INV-G10/LRN1): ה-curator מציע (status=analyzed),
היו"ר מאשרת, והלקחים נכתבים לערוצים שהכותב צורך (T15) — אין auto-commit.
- db.get_draft_final_pair(id) — שורת-פנקס מלאה כולל analysis.
- app.py: GET /api/learning/pairs/{id} (חושף רק changes מסוג style_method —
INV-LRN5) + POST .../promote (לקחים→discussion_rules['universal'],
ביטויים→transition_phrases['universal'] דרך merge ל-appeal_type_rules;
status→lessons_folded). _append_methodology_override משותף.
- web-ui: usePairDetail/usePromoteLearning + ProposalReview (בחירת לקחים/
ביטויים לאימוץ) בטאב "למידה" עבור pairs במצב analyzed.
INV-G10 (שער-יו"ר) · INV-LRN1 (אין auto-commit) · INV-LRN5 (טוהר).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, ChevronLeft } from "lucide-react";
|
||||
import { Loader2, ChevronLeft, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useReconciliationLedger, useStyleDistance,
|
||||
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
|
||||
type DraftFinalPair,
|
||||
} from "@/lib/api/learning";
|
||||
|
||||
@@ -64,6 +66,91 @@ function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SelectableItem({ text, selected, onToggle }: {
|
||||
text: string; selected: boolean; onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle}
|
||||
className={`w-full flex items-start gap-2 rounded-md border p-2 text-right text-[0.78rem] transition-colors ${
|
||||
selected ? "border-navy bg-navy-soft/15" : "border-rule bg-surface hover:bg-rule-soft/30"
|
||||
}`}>
|
||||
<span className={`mt-0.5 w-4 h-4 shrink-0 rounded border flex items-center justify-center ${
|
||||
selected ? "bg-navy border-navy text-parchment" : "border-rule"
|
||||
}`}>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="flex-1">{text}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** T14 — chair approval gate (INV-G10/LRN1): review the curator's style_method
|
||||
* proposal and fold approved lessons/phrases into writer-consumed channels. */
|
||||
function ProposalReview({ pairId }: { pairId: string }) {
|
||||
const { data, isPending, error } = usePairDetail(pairId);
|
||||
const promote = usePromoteLearning(pairId);
|
||||
const [lessons, setLessons] = useState<Set<string>>(new Set());
|
||||
const [phrases, setPhrases] = useState<Set<string>>(new Set());
|
||||
|
||||
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
||||
if (error || !data) return <p className="text-danger text-[0.75rem]">שגיאה בטעינת ההצעה.</p>;
|
||||
|
||||
const lessonTexts = data.changes.map((c) => c.lesson).filter((l): l is string => Boolean(l));
|
||||
const toggle = (set: Set<string>, setFn: (s: Set<string>) => void, v: string) => {
|
||||
const next = new Set(set);
|
||||
next.has(v) ? next.delete(v) : next.add(v);
|
||||
setFn(next);
|
||||
};
|
||||
const total = lessons.size + phrases.size;
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-gold-wash/30 border border-gold/40 p-3 space-y-3 text-[0.8rem]">
|
||||
<p className="text-ink-muted text-[0.75rem]">
|
||||
הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר).
|
||||
</p>
|
||||
{data.overall_assessment && (
|
||||
<p className="italic text-ink-muted">{data.overall_assessment}</p>
|
||||
)}
|
||||
{lessonTexts.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-semibold text-navy text-[0.75rem]">לקחי-סגנון → כללי-דיון</div>
|
||||
{lessonTexts.map((l, i) => (
|
||||
<SelectableItem key={`l${i}`} text={l} selected={lessons.has(l)}
|
||||
onToggle={() => toggle(lessons, setLessons, l)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.new_expressions.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-semibold text-navy text-[0.75rem]">ביטויי-מעבר חדשים</div>
|
||||
{data.new_expressions.map((p, i) => (
|
||||
<SelectableItem key={`p${i}`} text={p} selected={phrases.has(p)}
|
||||
onToggle={() => toggle(phrases, setPhrases, p)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{lessonTexts.length === 0 && data.new_expressions.length === 0 && (
|
||||
<p className="text-ink-muted">אין הצעות style_method.</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={total === 0 || promote.isPending}
|
||||
className="bg-navy text-parchment hover:bg-navy-soft"
|
||||
onClick={() => promote.mutate(
|
||||
{ lessons: [...lessons], phrases: [...phrases] },
|
||||
{
|
||||
onSuccess: (r) => toast.success(`הוטמעו ${r.folded_lessons} לקחים + ${r.folded_phrases} ביטויים`),
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : "שגיאה"),
|
||||
},
|
||||
)}>
|
||||
{promote.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
|
||||
: <Check className="w-3.5 h-3.5 me-1" />}
|
||||
אשר והטמע {total > 0 ? `(${total})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ pair }: { pair: DraftFinalPair }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@@ -82,8 +169,11 @@ function Row({ pair }: { pair: DraftFinalPair }) {
|
||||
{STATUS_LABEL[pair.status] ?? pair.status}
|
||||
</Badge>
|
||||
</button>
|
||||
{open && pair.case_number && (
|
||||
<div className="px-4 pb-3"><StyleDistanceDetail caseNumber={pair.case_number} /></div>
|
||||
{open && (
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
{pair.status === "analyzed" && <ProposalReview pairId={pair.id} />}
|
||||
{pair.case_number && <StyleDistanceDetail caseNumber={pair.case_number} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user