Add methodology settings page with golden ratios, discussion rules, and checklists
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
New /methodology page with 3 tabs for viewing and editing decision
writing methodology. Uses DB override pattern: hardcoded Python
constants serve as defaults, edits saved to appeal_type_rules table,
delete restores default.
Backend: 3 generic endpoints (GET/PUT/DELETE /api/methodology/{category}/{key})
with validation per category type.
Frontend: methodology.ts hooks, GoldenRatiosPanel (number inputs per
outcome/section), DiscussionRulesPanel (accordion with textarea per
rule), ContentChecklistsPanel (markdown editor with preview toggle).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
47
web-ui/src/app/methodology/page.tsx
Normal file
47
web-ui/src/app/methodology/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { GoldenRatiosPanel } from "@/components/methodology/golden-ratios-panel";
|
||||
import { DiscussionRulesPanel } from "@/components/methodology/discussion-rules-panel";
|
||||
import { ContentChecklistsPanel } from "@/components/methodology/content-checklists-panel";
|
||||
|
||||
export default function MethodologyPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-navy">מתודולוגיה</h1>
|
||||
<p className="text-sm text-ink-muted mt-1">
|
||||
הגדרות ניסוח — יחסי אורך, כללי דיון, וצ׳קליסטים לפי סוג ערר
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<Tabs defaultValue="ratios" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="ratios">יחסי זהב</TabsTrigger>
|
||||
<TabsTrigger value="rules">כללי דיון</TabsTrigger>
|
||||
<TabsTrigger value="checklists">צ׳קליסטים</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ratios" className="mt-5">
|
||||
<GoldenRatiosPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-5">
|
||||
<DiscussionRulesPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="checklists" className="mt-5">
|
||||
<ContentChecklistsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type NavItem = {
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/methodology", label: "מתודולוגיה" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
{ href: "/settings", label: "הגדרות" },
|
||||
|
||||
181
web-ui/src/components/methodology/content-checklists-panel.tsx
Normal file
181
web-ui/src/components/methodology/content-checklists-panel.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import {
|
||||
useMethodology,
|
||||
useUpdateMethodology,
|
||||
useResetMethodology,
|
||||
} from "@/lib/api/methodology";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
|
||||
const CHECKLIST_LABELS: Record<string, string> = {
|
||||
licensing_substantive: "ערר רישוי מהותי",
|
||||
licensing_threshold: "ערר רישוי סף/סמכות",
|
||||
licensing_property: "ערר רישוי קנייני",
|
||||
tama38: "תמ\"א 38",
|
||||
betterment_levy: "היטל השבחה",
|
||||
};
|
||||
|
||||
const CHECKLIST_ORDER = [
|
||||
"licensing_substantive",
|
||||
"licensing_threshold",
|
||||
"licensing_property",
|
||||
"tama38",
|
||||
"betterment_levy",
|
||||
];
|
||||
|
||||
type ChecklistItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
original: string;
|
||||
draft: string;
|
||||
isOverride: boolean;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export function ContentChecklistsPanel() {
|
||||
const { data, isLoading } = useMethodology<string>("content_checklists");
|
||||
const update = useUpdateMethodology("content_checklists");
|
||||
const reset = useResetMethodology("content_checklists");
|
||||
const [items, setItems] = useState<ChecklistItem[]>([]);
|
||||
const [active, setActive] = useState(CHECKLIST_ORDER[0]);
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.items) return;
|
||||
setItems(
|
||||
CHECKLIST_ORDER
|
||||
.filter((k) => k in data.items)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: CHECKLIST_LABELS[key] ?? key,
|
||||
original: data.items[key].value,
|
||||
draft: data.items[key].value,
|
||||
isOverride: data.items[key].is_override,
|
||||
dirty: false,
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const current = items.find((i) => i.key === active);
|
||||
|
||||
const updateDraft = (text: string) => {
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.key === active
|
||||
? { ...i, draft: text, dirty: text !== i.original }
|
||||
: i,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!current) return;
|
||||
update.mutate(
|
||||
{ key: current.key, value: current.draft },
|
||||
{
|
||||
onSuccess: () => toast.success(`${current.label} נשמר`),
|
||||
onError: () => toast.error("שגיאה בשמירה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!current) return;
|
||||
reset.mutate(current.key, {
|
||||
onSuccess: () => toast.success(`${current.label} אופס`),
|
||||
onError: () => toast.error("שגיאה באיפוס"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
טוען...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab selector */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.key}
|
||||
size="sm"
|
||||
variant={active === item.key ? "default" : "outline"}
|
||||
onClick={() => { setActive(item.key); setPreview(false); }}
|
||||
className="text-xs"
|
||||
>
|
||||
{item.label}
|
||||
{item.isOverride && (
|
||||
<Badge variant="secondary" className="text-[9px] mr-1.5 px-1">
|
||||
מותאם
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor / Preview */}
|
||||
{current && (
|
||||
<Card className="border-rule">
|
||||
<CardContent className="px-5 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-navy">{current.label}</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPreview(!preview)}
|
||||
className="text-xs"
|
||||
>
|
||||
{preview ? <EyeOff className="w-3.5 h-3.5 ml-1" /> : <Eye className="w-3.5 h-3.5 ml-1" />}
|
||||
{preview ? "עריכה" : "תצוגה מקדימה"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="border border-rule rounded-md p-4 bg-sand-soft/30 max-h-[500px] overflow-y-auto">
|
||||
<Markdown content={current.draft} />
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={current.draft}
|
||||
onChange={(e) => updateDraft(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm leading-relaxed"
|
||||
dir="rtl"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" disabled={!current.dirty || update.isPending} onClick={handleSave}>
|
||||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||||
שמור
|
||||
</Button>
|
||||
{current.isOverride && (
|
||||
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={handleReset}>
|
||||
<RotateCcw className="w-3 h-3 ml-1" />
|
||||
איפוס לברירת מחדל
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant={current.isOverride ? "default" : "secondary"}
|
||||
className="text-[10px] mr-auto"
|
||||
>
|
||||
{current.isOverride ? "מותאם" : "ברירת מחדל"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
web-ui/src/components/methodology/discussion-rules-panel.tsx
Normal file
188
web-ui/src/components/methodology/discussion-rules-panel.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useMethodology,
|
||||
useUpdateMethodology,
|
||||
useResetMethodology,
|
||||
} from "@/lib/api/methodology";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Plus, Trash2, Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
universal: "כללי (לכל סוגי התוצאות)",
|
||||
rejection: "דחייה",
|
||||
full_acceptance: "קבלה מלאה",
|
||||
partial_acceptance: "קבלה חלקית",
|
||||
betterment_levy: "היטל השבחה",
|
||||
};
|
||||
|
||||
type RulesSection = {
|
||||
key: string;
|
||||
label: string;
|
||||
original: string[];
|
||||
draft: string[];
|
||||
isOverride: boolean;
|
||||
dirty: boolean;
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
export function DiscussionRulesPanel() {
|
||||
const { data, isLoading } = useMethodology<string[]>("discussion_rules");
|
||||
const update = useUpdateMethodology("discussion_rules");
|
||||
const reset = useResetMethodology("discussion_rules");
|
||||
const [sections, setSections] = useState<RulesSection[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.items) return;
|
||||
const order = ["universal", "rejection", "partial_acceptance", "full_acceptance", "betterment_levy"];
|
||||
setSections(
|
||||
order
|
||||
.filter((k) => k in data.items)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: OUTCOME_LABELS[key] ?? key,
|
||||
original: data.items[key].value,
|
||||
draft: [...data.items[key].value],
|
||||
isOverride: data.items[key].is_override,
|
||||
dirty: false,
|
||||
expanded: key === "universal",
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, expanded: !s.expanded } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
const updateRule = (sIdx: number, rIdx: number, text: string) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== sIdx) return s;
|
||||
const draft = [...s.draft];
|
||||
draft[rIdx] = text;
|
||||
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const addRule = (sIdx: number) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== sIdx) return s;
|
||||
const draft = [...s.draft, ""];
|
||||
return { ...s, draft, dirty: true };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRule = (sIdx: number, rIdx: number) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== sIdx) return s;
|
||||
const draft = s.draft.filter((_, j) => j !== rIdx);
|
||||
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = (sec: RulesSection) => {
|
||||
const cleaned = sec.draft.filter((s) => s.trim());
|
||||
update.mutate(
|
||||
{ key: sec.key, value: cleaned },
|
||||
{
|
||||
onSuccess: () => toast.success(`${sec.label} נשמר`),
|
||||
onError: () => toast.error("שגיאה בשמירה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = (sec: RulesSection) => {
|
||||
reset.mutate(sec.key, {
|
||||
onSuccess: () => toast.success(`${sec.label} אופס`),
|
||||
onError: () => toast.error("שגיאה באיפוס"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
טוען...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sections.map((sec, si) => (
|
||||
<Card key={sec.key} className="border-rule">
|
||||
<CardContent className="px-5 py-0">
|
||||
{/* Accordion header */}
|
||||
<button
|
||||
className="flex items-center justify-between w-full py-3 text-right"
|
||||
onClick={() => toggle(si)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-navy">{sec.label}</h3>
|
||||
<Badge variant={sec.isOverride ? "default" : "secondary"} className="text-[10px]">
|
||||
{sec.isOverride ? "מותאם" : "ברירת מחדל"}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-ink-faint">{sec.draft.length} כללים</span>
|
||||
</div>
|
||||
{sec.expanded ? <ChevronUp className="w-4 h-4 text-ink-faint" /> : <ChevronDown className="w-4 h-4 text-ink-faint" />}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{sec.expanded && (
|
||||
<div className="pb-4 space-y-2">
|
||||
{sec.draft.map((rule, ri) => (
|
||||
<div key={ri} className="flex gap-2">
|
||||
<Textarea
|
||||
value={rule}
|
||||
onChange={(e) => updateRule(si, ri, e.target.value)}
|
||||
className="min-h-[60px] text-sm flex-1"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-600 flex-shrink-0 mt-1"
|
||||
onClick={() => removeRule(si, ri)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => addRule(si)}>
|
||||
<Plus className="w-3 h-3 ml-1" />
|
||||
הוסף כלל
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-rule/50">
|
||||
<Button size="sm" disabled={!sec.dirty || update.isPending} onClick={() => handleSave(sec)}>
|
||||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||||
שמור
|
||||
</Button>
|
||||
{sec.isOverride && (
|
||||
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={() => handleReset(sec)}>
|
||||
<RotateCcw className="w-3 h-3 ml-1" />
|
||||
איפוס
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
web-ui/src/components/methodology/golden-ratios-panel.tsx
Normal file
177
web-ui/src/components/methodology/golden-ratios-panel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
useMethodology,
|
||||
useUpdateMethodology,
|
||||
useResetMethodology,
|
||||
type GoldenRatios,
|
||||
} from "@/lib/api/methodology";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Loader2 } from "lucide-react";
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
rejection: "דחייה",
|
||||
full_acceptance: "קבלה מלאה",
|
||||
partial_acceptance: "קבלה חלקית",
|
||||
betterment_levy: "היטל השבחה",
|
||||
};
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
background: "רקע",
|
||||
claims: "טענות",
|
||||
discussion: "דיון",
|
||||
summary: "סיכום",
|
||||
};
|
||||
|
||||
type RatioCard = {
|
||||
key: string;
|
||||
label: string;
|
||||
original: GoldenRatios;
|
||||
draft: GoldenRatios;
|
||||
isOverride: boolean;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export function GoldenRatiosPanel() {
|
||||
const { data, isLoading } = useMethodology<GoldenRatios>("golden_ratios");
|
||||
const update = useUpdateMethodology("golden_ratios");
|
||||
const reset = useResetMethodology("golden_ratios");
|
||||
const [cards, setCards] = useState<RatioCard[]>([]);
|
||||
|
||||
// Sync from server
|
||||
useEffect(() => {
|
||||
if (!data?.items) return;
|
||||
setCards(
|
||||
Object.entries(data.items).map(([key, item]) => ({
|
||||
key,
|
||||
label: OUTCOME_LABELS[key] ?? key,
|
||||
original: item.value,
|
||||
draft: structuredClone(item.value),
|
||||
isOverride: item.is_override,
|
||||
dirty: false,
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const setRange = (cardIdx: number, section: string, idx: 0 | 1, val: number) => {
|
||||
setCards((prev) =>
|
||||
prev.map((c, i) => {
|
||||
if (i !== cardIdx) return c;
|
||||
const next = { ...c, draft: { ...c.draft, [section]: [...c.draft[section]] as [number, number] } };
|
||||
next.draft[section][idx] = val;
|
||||
next.dirty = JSON.stringify(next.draft) !== JSON.stringify(next.original);
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = (card: RatioCard) => {
|
||||
update.mutate(
|
||||
{ key: card.key, value: card.draft },
|
||||
{
|
||||
onSuccess: () => toast.success(`${card.label} נשמר`),
|
||||
onError: () => toast.error("שגיאה בשמירה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = (card: RatioCard) => {
|
||||
reset.mutate(card.key, {
|
||||
onSuccess: () => toast.success(`${card.label} אופס לברירת מחדל`),
|
||||
onError: () => toast.error("שגיאה באיפוס"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
טוען...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{cards.map((card, ci) => (
|
||||
<Card key={card.key} className="border-rule">
|
||||
<CardContent className="px-5 py-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-navy">{card.label}</h3>
|
||||
<Badge variant={card.isOverride ? "default" : "secondary"} className="text-[10px]">
|
||||
{card.isOverride ? "מותאם" : "ברירת מחדל"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-ink-faint text-[11px]">
|
||||
<th className="text-right py-1">Section</th>
|
||||
<th className="text-center py-1">Min %</th>
|
||||
<th className="text-center py-1">Max %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(SECTION_LABELS).map(([sec, label]) => (
|
||||
<tr key={sec} className="border-t border-rule/50">
|
||||
<td className="py-1.5 text-ink">{label}</td>
|
||||
<td className="py-1.5 px-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={card.draft[sec]?.[0] ?? 0}
|
||||
onChange={(e) => setRange(ci, sec, 0, Number(e.target.value))}
|
||||
className="h-7 w-16 text-center text-xs mx-auto"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 px-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={card.draft[sec]?.[1] ?? 0}
|
||||
onChange={(e) => setRange(ci, sec, 1, Number(e.target.value))}
|
||||
className="h-7 w-16 text-center text-xs mx-auto"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!card.dirty || update.isPending}
|
||||
onClick={() => handleSave(card)}
|
||||
>
|
||||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||||
שמור
|
||||
</Button>
|
||||
{card.isOverride && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reset.isPending}
|
||||
onClick={() => handleReset(card)}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 ml-1" />
|
||||
איפוס
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
web-ui/src/lib/api/methodology.ts
Normal file
71
web-ui/src/lib/api/methodology.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Methodology settings hooks — view and edit golden ratios,
|
||||
* discussion rules, and content checklists.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export type MethodologyItem<T = unknown> = {
|
||||
value: T;
|
||||
is_override: boolean;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type MethodologyResponse<T = unknown> = {
|
||||
items: Record<string, MethodologyItem<T>>;
|
||||
};
|
||||
|
||||
/** Golden ratio per section: [min%, max%] */
|
||||
export type GoldenRatios = Record<string, [number, number]>;
|
||||
|
||||
// ── Query Keys ───────────────────────────────────────────────────
|
||||
|
||||
export const methodologyKeys = {
|
||||
all: ["methodology"] as const,
|
||||
category: (cat: string) => [...methodologyKeys.all, cat] as const,
|
||||
};
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useMethodology<T = unknown>(category: string) {
|
||||
return useQuery({
|
||||
queryKey: methodologyKeys.category(category),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<MethodologyResponse<T>>(
|
||||
`/api/methodology/${category}`,
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMethodology(category: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
|
||||
apiRequest<{ key: string; value: unknown; is_override: boolean }>(
|
||||
`/api/methodology/${category}/${key}`,
|
||||
{ method: "PUT", body: { value } },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: methodologyKeys.category(category) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetMethodology(category: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
apiRequest<{ key: string; value: unknown; is_override: boolean }>(
|
||||
`/api/methodology/${category}/${key}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: methodologyKeys.category(category) });
|
||||
},
|
||||
});
|
||||
}
|
||||
101
web/app.py
101
web/app.py
@@ -22,6 +22,7 @@ import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
import asyncpg
|
||||
@@ -2332,6 +2333,106 @@ async def api_delete_tag_mapping(mapping_id: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Methodology Settings ───────────────────────────────────────────
|
||||
|
||||
from legal_mcp.services.lessons import (
|
||||
GOLDEN_RATIOS,
|
||||
DISCUSSION_RULES,
|
||||
CONTENT_CHECKLISTS,
|
||||
)
|
||||
|
||||
_METHODOLOGY_DEFAULTS: dict[str, dict] = {
|
||||
"golden_ratios": {k: {s: list(v) for s, v in sec.items()} for k, sec in GOLDEN_RATIOS.items()},
|
||||
"discussion_rules": dict(DISCUSSION_RULES),
|
||||
"content_checklists": dict(CONTENT_CHECKLISTS),
|
||||
}
|
||||
|
||||
_VALID_CATEGORIES = set(_METHODOLOGY_DEFAULTS.keys())
|
||||
|
||||
|
||||
@app.get("/api/methodology/{category}")
|
||||
async def api_get_methodology(category: str):
|
||||
"""Get methodology settings with DB overrides merged over defaults."""
|
||||
if category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(400, f"Unknown category: {category}. Valid: {sorted(_VALID_CATEGORIES)}")
|
||||
|
||||
defaults = _METHODOLOGY_DEFAULTS[category]
|
||||
pool = await db.get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT rule_key, rule_value, created_at FROM appeal_type_rules "
|
||||
"WHERE appeal_type = '_global' AND rule_category = $1",
|
||||
category,
|
||||
)
|
||||
overrides = {r["rule_key"]: r for r in rows}
|
||||
|
||||
items = {}
|
||||
for key, default_val in defaults.items():
|
||||
if key in overrides:
|
||||
items[key] = {
|
||||
"value": overrides[key]["rule_value"],
|
||||
"is_override": True,
|
||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||
}
|
||||
else:
|
||||
items[key] = {"value": default_val, "is_override": False, "updated_at": None}
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
class MethodologyUpdateRequest(BaseModel):
|
||||
value: Any
|
||||
|
||||
|
||||
@app.put("/api/methodology/{category}/{key}")
|
||||
async def api_update_methodology(category: str, key: str, req: MethodologyUpdateRequest):
|
||||
"""Upsert a methodology override. Validates value shape per category."""
|
||||
if category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(400, f"Unknown category: {category}")
|
||||
if key not in _METHODOLOGY_DEFAULTS[category]:
|
||||
raise HTTPException(400, f"Unknown key '{key}' for category '{category}'")
|
||||
|
||||
# Validate value shape
|
||||
if category == "golden_ratios":
|
||||
if not isinstance(req.value, dict):
|
||||
raise HTTPException(422, "golden_ratios value must be a dict of section → [min, max]")
|
||||
for sec, rng in req.value.items():
|
||||
if not (isinstance(rng, list) and len(rng) == 2 and all(isinstance(x, (int, float)) for x in rng)):
|
||||
raise HTTPException(422, f"Section '{sec}' must be [min, max] (integers 0-100)")
|
||||
elif category == "discussion_rules":
|
||||
if not isinstance(req.value, list) or not all(isinstance(s, str) and s.strip() for s in req.value):
|
||||
raise HTTPException(422, "discussion_rules value must be a list of non-empty strings")
|
||||
elif category == "content_checklists":
|
||||
if not isinstance(req.value, str) or not req.value.strip():
|
||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||
|
||||
pool = await db.get_pool()
|
||||
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::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
|
||||
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||
)
|
||||
|
||||
return {"key": key, "value": req.value, "is_override": True}
|
||||
|
||||
|
||||
@app.delete("/api/methodology/{category}/{key}")
|
||||
async def api_reset_methodology(category: str, key: str):
|
||||
"""Delete methodology override, restoring the hardcoded default."""
|
||||
if category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(400, f"Unknown category: {category}")
|
||||
if key not in _METHODOLOGY_DEFAULTS[category]:
|
||||
raise HTTPException(400, f"Unknown key '{key}' for category '{category}'")
|
||||
|
||||
pool = await db.get_pool()
|
||||
await pool.execute(
|
||||
"DELETE FROM appeal_type_rules WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2",
|
||||
category, key,
|
||||
)
|
||||
|
||||
return {"key": key, "value": _METHODOLOGY_DEFAULTS[category][key], "is_override": False}
|
||||
|
||||
|
||||
# ── Skill Management API ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user