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:
2026-06-06 19:17:56 +00:00
parent ee76455a9a
commit f20a3a09fd
4 changed files with 244 additions and 5 deletions

View File

@@ -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>
);

View File

@@ -3,7 +3,7 @@
* Backs the /training "למידה" tab. Endpoints under /api/learning.
*/
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type DraftFinalPair = {
@@ -67,3 +67,50 @@ export function useStyleDistance(caseNumber: string | null) {
staleTime: 15_000,
});
}
// ── T14: curator distillation proposal + chair approval gate ──────
export type DistillationChange = {
type?: string;
domain?: string;
block?: string;
description?: string;
draft_text?: string;
final_text?: string;
lesson?: string;
};
export type PairDetail = {
id: string;
case_number: string;
title: string;
status: string;
diff_stats: { change_percent?: number } | null;
overall_assessment: string;
changes: DistillationChange[]; // style_method only (server-filtered)
new_expressions: string[];
};
export function usePairDetail(pairId: string | null) {
return useQuery({
queryKey: [...learningKeys.all, "pair", pairId ?? ""] as const,
queryFn: ({ signal }) =>
apiRequest<PairDetail>(`/api/learning/pairs/${pairId}`, { signal }),
enabled: Boolean(pairId),
staleTime: 15_000,
});
}
export function usePromoteLearning(pairId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { lessons: string[]; phrases: string[] }) =>
apiRequest<{ id: string; status: string; folded_lessons: number; folded_phrases: number }>(
`/api/learning/pairs/${pairId}/promote`,
{ method: "POST", body },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: learningKeys.all });
},
});
}