Merge pull request 'feat(style-acq T14): שער-יו"ר לאישור הצעות-curator → הטמעה לפרופיל' (#87) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
This commit was merged in pull request #87.
This commit is contained in:
@@ -2352,6 +2352,21 @@ async def list_draft_final_pairs(status: str | None = None, limit: int = 200) ->
|
|||||||
return [dict(r) for r in rows]
|
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(
|
async def insert_style_exemplar(
|
||||||
decision_number: str, source: str, practice_area: str, outcome: str,
|
decision_number: str, source: str, practice_area: str, outcome: str,
|
||||||
section: str, paragraph_text: str, word_count: int, embedding: list[float],
|
section: str, paragraph_text: str, word_count: int, embedding: list[float],
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
useReconciliationLedger, useStyleDistance,
|
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
|
||||||
type DraftFinalPair,
|
type DraftFinalPair,
|
||||||
} from "@/lib/api/learning";
|
} 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 }) {
|
function Row({ pair }: { pair: DraftFinalPair }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -82,8 +169,11 @@ function Row({ pair }: { pair: DraftFinalPair }) {
|
|||||||
{STATUS_LABEL[pair.status] ?? pair.status}
|
{STATUS_LABEL[pair.status] ?? pair.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
{open && pair.case_number && (
|
{open && (
|
||||||
<div className="px-4 pb-3"><StyleDistanceDetail caseNumber={pair.case_number} /></div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Backs the /training "למידה" tab. Endpoints under /api/learning.
|
* 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";
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
export type DraftFinalPair = {
|
export type DraftFinalPair = {
|
||||||
@@ -67,3 +67,50 @@ export function useStyleDistance(caseNumber: string | null) {
|
|||||||
staleTime: 15_000,
|
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)
|
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 ───────────────────────────────────────────
|
# ── Skill Management API ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user