feat(style-acq T14): שער-יו"ר לאישור הצעות-curator → הטמעה לפרופיל #87
@@ -2352,6 +2352,21 @@ async def list_draft_final_pairs(status: str | None = None, limit: int = 200) ->
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_draft_final_pair(pair_id: UUID) -> dict | None:
|
||||
"""Full pairing row incl. analysis (curator proposal) — for the T14 approval gate."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT p.id, p.case_id, c.case_number, c.title, p.status,
|
||||
p.draft_text, p.final_text, p.diff_stats, p.analysis,
|
||||
p.created_at, p.updated_at
|
||||
FROM draft_final_pairs p LEFT JOIN cases c ON c.id = p.case_id
|
||||
WHERE p.id = $1""",
|
||||
pair_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def insert_style_exemplar(
|
||||
decision_number: str, source: str, practice_area: str, outcome: str,
|
||||
section: str, paragraph_text: str, word_count: int, embedding: list[float],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
87
web/app.py
87
web/app.py
@@ -4151,6 +4151,93 @@ async def api_learning_style_distance(case_number: str):
|
||||
return await _sd.style_distance(case_number)
|
||||
|
||||
|
||||
def _coerce_json(raw):
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
@app.get("/api/learning/pairs/{pair_id}")
|
||||
async def api_learning_pair_detail(pair_id: str):
|
||||
"""פירוט שורת-פנקס כולל הצעת-הדיסטילציה (analysis) לאישור יו"ר (T14)."""
|
||||
try:
|
||||
pid = UUID(pair_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "pair_id לא תקין")
|
||||
p = await db.get_draft_final_pair(pid)
|
||||
if not p:
|
||||
raise HTTPException(404, "לא נמצא")
|
||||
analysis = _coerce_json(p.get("analysis")) or {}
|
||||
# surface only style_method changes (INV-LRN5 — substance never enters the voice)
|
||||
changes = [c for c in analysis.get("changes", []) if c.get("domain") == "style_method"]
|
||||
return {
|
||||
"id": str(p["id"]),
|
||||
"case_number": p.get("case_number") or "",
|
||||
"title": p.get("title") or "",
|
||||
"status": p.get("status") or "",
|
||||
"diff_stats": _coerce_json(p.get("diff_stats")),
|
||||
"overall_assessment": analysis.get("overall_assessment", ""),
|
||||
"changes": changes,
|
||||
"new_expressions": analysis.get("new_expressions", []),
|
||||
}
|
||||
|
||||
|
||||
class PromoteLearningRequest(BaseModel):
|
||||
lessons: list[str] = [] # style_method lessons → discussion_rules['universal']
|
||||
phrases: list[str] = [] # new transition phrases → transition_phrases['universal']
|
||||
|
||||
|
||||
async def _append_methodology_override(category: str, key: str, items: list[str]) -> None:
|
||||
"""Read current (override-or-default) list value, append new items, upsert override.
|
||||
Shared by the T14 approval gate to fold approved learnings into writer-consumed channels."""
|
||||
pool = await db.get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT rule_value FROM appeal_type_rules "
|
||||
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2",
|
||||
category, key,
|
||||
)
|
||||
if row:
|
||||
current = _coerce_json(row["rule_value"]) or []
|
||||
else:
|
||||
current = list(_METHODOLOGY_DEFAULTS.get(category, {}).get(key, []))
|
||||
if not isinstance(current, list):
|
||||
current = []
|
||||
merged = current + [s for s in items if s and s not in current]
|
||||
await pool.execute(
|
||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||
category, key, json.dumps(merged, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/learning/pairs/{pair_id}/promote")
|
||||
async def api_learning_promote(pair_id: str, req: PromoteLearningRequest):
|
||||
"""שער-יו"ר (INV-G10/LRN1): מאשר לקחי-סגנון + ביטויי-מעבר מהצעת-הדיסטילציה
|
||||
ומטמיע אותם בערוצים שהכותב צורך (methodology overrides → T15). מקדם status."""
|
||||
try:
|
||||
pid = UUID(pair_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "pair_id לא תקין")
|
||||
p = await db.get_draft_final_pair(pid)
|
||||
if not p:
|
||||
raise HTTPException(404, "לא נמצא")
|
||||
|
||||
if req.lessons:
|
||||
await _append_methodology_override("discussion_rules", "universal", req.lessons)
|
||||
if req.phrases:
|
||||
await _append_methodology_override("transition_phrases", "universal", req.phrases)
|
||||
|
||||
await db.update_draft_final_pair(pid, status="lessons_folded")
|
||||
return {
|
||||
"id": pair_id, "status": "lessons_folded",
|
||||
"folded_lessons": len(req.lessons), "folded_phrases": len(req.phrases),
|
||||
}
|
||||
|
||||
|
||||
# ── Skill Management API ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user