Keith Yu commited on
Commit
7b14728
·
1 Parent(s): 533af38

merging changes

Browse files
Files changed (4) hide show
  1. backend/app.py +56 -0
  2. backend/supabase_schema.sql +56 -0
  3. index.html +5 -1
  4. script.js +117 -3
backend/app.py CHANGED
@@ -44,6 +44,11 @@ SUPABASE_KEY = (
44
  )
45
  SUPABASE_CLAIMS_BUCKET = (os.getenv("SUPABASE_CLAIMS_BUCKET") or "").strip()
46
  SUPABASE_CLAIMS_PREFIX = (os.getenv("SUPABASE_CLAIMS_PREFIX") or "").strip().strip("/")
 
 
 
 
 
47
 
48
  _supabase_client = None
49
 
@@ -204,6 +209,44 @@ def _next_id(existing_ids: List[str], prefix: str) -> str:
204
 
205
 
206
  def _load_taxonomy() -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  codebook = _read_claim_json(CODEBOOK_NAME)
208
  superclaims = _read_claim_json(SUPERCLAIMS_NAME)
209
  claim_map = _read_claim_json(MAP_NAME)
@@ -350,6 +393,7 @@ class ProposalActionResponse(BaseModel):
350
 
351
  class ReviewerActionRequest(BaseModel):
352
  reviewer_name: str = Field(..., min_length=1, max_length=120)
 
353
 
354
 
355
  def _parse_ts_to_epoch(value: Any) -> float:
@@ -876,6 +920,18 @@ def apply_proposal(proposal_id: str, req: ReviewerActionRequest) -> ProposalActi
876
 
877
  codebook, superclaims, claim_map = _load_taxonomy()
878
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  if p.type == "new_superclaim":
880
  text = str(p.payload.get("superclaimText") or "").strip()
881
  if not text:
 
44
  )
45
  SUPABASE_CLAIMS_BUCKET = (os.getenv("SUPABASE_CLAIMS_BUCKET") or "").strip()
46
  SUPABASE_CLAIMS_PREFIX = (os.getenv("SUPABASE_CLAIMS_PREFIX") or "").strip().strip("/")
47
+ SUPABASE_TAXONOMY_TABLES = (os.getenv("SUPABASE_TAXONOMY_TABLES") or "").strip().lower() in (
48
+ "1",
49
+ "true",
50
+ "yes",
51
+ )
52
 
53
  _supabase_client = None
54
 
 
209
 
210
 
211
  def _load_taxonomy() -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str]]:
212
+ if SUPABASE_TAXONOMY_TABLES and _supabase_enabled():
213
+ sb = _get_supabase()
214
+ try:
215
+ sc_rows = sb.table("taxonomy_superclaims").select("id,text").execute().data or []
216
+ nc_rows = (
217
+ sb.table("taxonomy_subclaims")
218
+ .select("id,text,superclaim_id")
219
+ .execute()
220
+ .data
221
+ or []
222
+ )
223
+ except Exception as e:
224
+ raise HTTPException(status_code=500, detail=f"Supabase taxonomy table read failed: {e}") from e
225
+
226
+ super_norm = {}
227
+ for r in sc_rows:
228
+ if not isinstance(r, dict):
229
+ continue
230
+ sid = _normalize_superclaim_id(str(r.get("id") or "").strip())
231
+ txt = str(r.get("text") or "").strip()
232
+ if sid and txt:
233
+ super_norm[sid] = txt
234
+
235
+ codebook_norm = {}
236
+ map_norm = {}
237
+ for r in nc_rows:
238
+ if not isinstance(r, dict):
239
+ continue
240
+ nid = _normalize_subclaim_id(str(r.get("id") or "").strip())
241
+ txt = str(r.get("text") or "").strip()
242
+ sc = _normalize_superclaim_id(str(r.get("superclaim_id") or "").strip())
243
+ if nid and txt:
244
+ codebook_norm[nid] = txt
245
+ if nid and sc:
246
+ map_norm[nid] = sc
247
+
248
+ return codebook_norm, super_norm, map_norm
249
+
250
  codebook = _read_claim_json(CODEBOOK_NAME)
251
  superclaims = _read_claim_json(SUPERCLAIMS_NAME)
252
  claim_map = _read_claim_json(MAP_NAME)
 
393
 
394
  class ReviewerActionRequest(BaseModel):
395
  reviewer_name: str = Field(..., min_length=1, max_length=120)
396
+ skip_taxonomy_update: bool = False
397
 
398
 
399
  def _parse_ts_to_epoch(value: Any) -> float:
 
920
 
921
  codebook, superclaims, claim_map = _load_taxonomy()
922
 
923
+ # In "browser-writes-taxonomy" mode, the frontend will update Supabase tables directly.
924
+ # The backend should only mark/log the proposal as applied.
925
+ if req.skip_taxonomy_update:
926
+ now = time.time()
927
+ p.payload = {**p.payload, "appliedAt": now}
928
+ p.appliedBy = req.reviewer_name.strip()
929
+ p.appliedAt = now
930
+ if p.type in ("merge_subclaims", "merge_superclaims"):
931
+ _append_merge_event(p)
932
+ p = _upsert_proposal(p)
933
+ return ProposalActionResponse(ok=True, proposal=p)
934
+
935
  if p.type == "new_superclaim":
936
  text = str(p.payload.get("superclaimText") or "").strip()
937
  if not text:
backend/supabase_schema.sql CHANGED
@@ -41,3 +41,59 @@ create table if not exists public.taxonomy_merge_log (
41
 
42
  alter table public.taxonomy_merge_log enable row level security;
43
  revoke all on public.taxonomy_merge_log from anon, authenticated;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  alter table public.taxonomy_merge_log enable row level security;
43
  revoke all on public.taxonomy_merge_log from anon, authenticated;
44
+
45
+ -- ============================================================================
46
+ -- Public taxonomy tables (browser read/write, NO auth)
47
+ --
48
+ -- WARNING: The policies below intentionally allow anonymous users to mutate
49
+ -- taxonomy. Only enable this for trusted/private deployments.
50
+ -- ============================================================================
51
+
52
+ create table if not exists public.taxonomy_superclaims (
53
+ id text primary key,
54
+ text text not null,
55
+ created_at timestamptz not null default now(),
56
+ updated_at timestamptz not null default now()
57
+ );
58
+
59
+ create table if not exists public.taxonomy_subclaims (
60
+ id text primary key,
61
+ text text not null,
62
+ superclaim_id text not null references public.taxonomy_superclaims(id) on update cascade on delete restrict,
63
+ created_at timestamptz not null default now(),
64
+ updated_at timestamptz not null default now()
65
+ );
66
+
67
+ create index if not exists taxonomy_subclaims_superclaim_idx
68
+ on public.taxonomy_subclaims (superclaim_id);
69
+
70
+ alter table public.taxonomy_superclaims enable row level security;
71
+ alter table public.taxonomy_subclaims enable row level security;
72
+
73
+ -- Public read/write policies (anon + authenticated).
74
+ drop policy if exists "public superclaims read" on public.taxonomy_superclaims;
75
+ create policy "public superclaims read"
76
+ on public.taxonomy_superclaims for select
77
+ using (true);
78
+
79
+ drop policy if exists "public superclaims write" on public.taxonomy_superclaims;
80
+ create policy "public superclaims write"
81
+ on public.taxonomy_superclaims for all
82
+ using (true)
83
+ with check (true);
84
+
85
+ drop policy if exists "public subclaims read" on public.taxonomy_subclaims;
86
+ create policy "public subclaims read"
87
+ on public.taxonomy_subclaims for select
88
+ using (true);
89
+
90
+ drop policy if exists "public subclaims write" on public.taxonomy_subclaims;
91
+ create policy "public subclaims write"
92
+ on public.taxonomy_subclaims for all
93
+ using (true)
94
+ with check (true);
95
+
96
+ -- Allow PostgREST access for anon/authenticated roles.
97
+ grant usage on schema public to anon, authenticated;
98
+ grant select, insert, update, delete on public.taxonomy_superclaims to anon, authenticated;
99
+ grant select, insert, update, delete on public.taxonomy_subclaims to anon, authenticated;
index.html CHANGED
@@ -5,8 +5,12 @@
5
  <title>Native Ads Claim Mapping</title>
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
  <!-- Optional: set to your deployed API origin when not using a same-origin /api rewrite (e.g. file:// or custom static hosting). Example: https://claims-backend-sigma.vercel.app -->
8
- <meta name="claims-api-base" content="" />
 
 
 
9
  <link rel="stylesheet" href="styles.css" />
 
10
  </head>
11
  <body>
12
  <main class="app">
 
5
  <title>Native Ads Claim Mapping</title>
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
  <!-- Optional: set to your deployed API origin when not using a same-origin /api rewrite (e.g. file:// or custom static hosting). Example: https://claims-backend-sigma.vercel.app -->
8
+ <meta name="claims-api-base" content="https://claims-backend-sigma.vercel.app" />
9
+ <!-- Optional: enable browser write access to Supabase taxonomy tables -->
10
+ <meta name="supabase-url" content="" />
11
+ <meta name="supabase-anon-key" content="" />
12
  <link rel="stylesheet" href="styles.css" />
13
+ <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
14
  </head>
15
  <body>
16
  <main class="app">
script.js CHANGED
@@ -701,6 +701,108 @@ function escapeHtml(s) {
701
 
702
  const REVIEWER_STORAGE_KEY = "CLAIMS_REVIEWER_NAME";
703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  function getReviewerName() {
705
  const el = document.getElementById("reviewer-name");
706
  const fromInput = el && el.value != null ? String(el.value).trim() : "";
@@ -1064,8 +1166,10 @@ async function refreshPendingProposals() {
1064
 
1065
  const wrap = document.createElement("div");
1066
  wrap.className = "proposal-list";
 
1067
 
1068
  proposals.forEach((p) => {
 
1069
  const card = document.createElement("article");
1070
  card.className = "proposal-card";
1071
  card.innerHTML = `
@@ -1107,9 +1211,19 @@ async function refreshPendingProposals() {
1107
  el.disabled = false;
1108
  return;
1109
  }
1110
- await postJson(`/api/proposals/${encodeURIComponent(id)}/${action}`, {
1111
- reviewer_name: reviewer,
1112
- });
 
 
 
 
 
 
 
 
 
 
1113
  // Applying should also refresh claims data next run; for now just refresh the list.
1114
  await refreshPendingProposals();
1115
  } catch (e) {
 
701
 
702
  const REVIEWER_STORAGE_KEY = "CLAIMS_REVIEWER_NAME";
703
 
704
+ function getSupabaseConfig() {
705
+ const metaUrl = document.querySelector('meta[name="supabase-url"]');
706
+ const metaKey = document.querySelector('meta[name="supabase-anon-key"]');
707
+ const url = (metaUrl && metaUrl.getAttribute("content")) || "";
708
+ const key = (metaKey && metaKey.getAttribute("content")) || "";
709
+ return { url: String(url || "").trim(), key: String(key || "").trim() };
710
+ }
711
+
712
+ function getSupabaseClient() {
713
+ const cfg = getSupabaseConfig();
714
+ if (!cfg.url || !cfg.key) return null;
715
+ if (typeof window === "undefined") return null;
716
+ if (!window.supabase || typeof window.supabase.createClient !== "function") return null;
717
+ try {
718
+ return window.supabase.createClient(cfg.url, cfg.key);
719
+ } catch {
720
+ return null;
721
+ }
722
+ }
723
+
724
+ function nextIdFromRows(rows, prefix) {
725
+ let max = 0;
726
+ for (const r of rows || []) {
727
+ const id = String(r?.id || "");
728
+ if (!id.startsWith(prefix)) continue;
729
+ const n = parseInt(id.slice(prefix.length), 10);
730
+ if (!Number.isNaN(n)) max = Math.max(max, n);
731
+ }
732
+ return `${prefix}${max + 1}`;
733
+ }
734
+
735
+ async function applyProposalToSupabaseTaxonomy(p) {
736
+ const sb = getSupabaseClient();
737
+ if (!sb) throw new Error("Supabase taxonomy config missing (set meta supabase-url and supabase-anon-key).");
738
+
739
+ const type = String(p?.type || "");
740
+ const payload = p?.payload || {};
741
+
742
+ if (type === "new_superclaim") {
743
+ const text = String(payload.superclaimText || "").trim();
744
+ if (!text) throw new Error("Missing superclaimText");
745
+
746
+ const { data: existing, error: selErr } = await sb
747
+ .from("taxonomy_superclaims")
748
+ .select("id")
749
+ .order("id", { ascending: false });
750
+ if (selErr) throw new Error(selErr.message || String(selErr));
751
+
752
+ const newId = nextIdFromRows(existing || [], "SC_");
753
+ const { error: insErr } = await sb.from("taxonomy_superclaims").insert({ id: newId, text });
754
+ if (insErr) throw new Error(insErr.message || String(insErr));
755
+ return;
756
+ }
757
+
758
+ if (type === "merge_subclaims") {
759
+ const canonical = String(payload.canonicalSubclaimId || "").trim();
760
+ const remove = String(payload.removeSubclaimId || "").trim();
761
+ if (!canonical || !remove || canonical === remove) throw new Error("Missing canonical/remove subclaim ids");
762
+
763
+ const { data: rows, error } = await sb
764
+ .from("taxonomy_subclaims")
765
+ .select("id,superclaim_id")
766
+ .in("id", [canonical, remove]);
767
+ if (error) throw new Error(error.message || String(error));
768
+ const canonRow = (rows || []).find((r) => String(r.id) === canonical);
769
+ const remRow = (rows || []).find((r) => String(r.id) === remove);
770
+ if (!canonRow || !remRow) throw new Error("Unknown subclaim id(s) in taxonomy_subclaims");
771
+ if (String(canonRow.superclaim_id) !== String(remRow.superclaim_id)) {
772
+ throw new Error("Refusing merge: subclaims are not mapped to the same superclaim.");
773
+ }
774
+
775
+ const { error: delErr } = await sb.from("taxonomy_subclaims").delete().eq("id", remove);
776
+ if (delErr) throw new Error(delErr.message || String(delErr));
777
+ return;
778
+ }
779
+
780
+ if (type === "merge_superclaims") {
781
+ const canonical = String(payload.canonicalSuperclaimId || "").trim();
782
+ const remove = String(payload.removeSuperclaimId || "").trim();
783
+ if (!canonical || !remove || canonical === remove) throw new Error("Missing canonical/remove superclaim ids");
784
+
785
+ const { data: rows, error } = await sb
786
+ .from("taxonomy_superclaims")
787
+ .select("id")
788
+ .in("id", [canonical, remove]);
789
+ if (error) throw new Error(error.message || String(error));
790
+ if (!Array.isArray(rows) || rows.length !== 2) throw new Error("Unknown superclaim id(s) in taxonomy_superclaims");
791
+
792
+ const { error: updErr } = await sb
793
+ .from("taxonomy_subclaims")
794
+ .update({ superclaim_id: canonical })
795
+ .eq("superclaim_id", remove);
796
+ if (updErr) throw new Error(updErr.message || String(updErr));
797
+
798
+ const { error: delErr } = await sb.from("taxonomy_superclaims").delete().eq("id", remove);
799
+ if (delErr) throw new Error(delErr.message || String(delErr));
800
+ return;
801
+ }
802
+
803
+ throw new Error(`Unsupported proposal type for browser taxonomy apply: ${type}`);
804
+ }
805
+
806
  function getReviewerName() {
807
  const el = document.getElementById("reviewer-name");
808
  const fromInput = el && el.value != null ? String(el.value).trim() : "";
 
1166
 
1167
  const wrap = document.createElement("div");
1168
  wrap.className = "proposal-list";
1169
+ const byId = new Map();
1170
 
1171
  proposals.forEach((p) => {
1172
+ if (p && p.id) byId.set(String(p.id), p);
1173
  const card = document.createElement("article");
1174
  card.className = "proposal-card";
1175
  card.innerHTML = `
 
1211
  el.disabled = false;
1212
  return;
1213
  }
1214
+ if (action === "apply" && getSupabaseClient()) {
1215
+ const p = byId.get(String(id));
1216
+ if (!p) throw new Error("Missing proposal payload in UI.");
1217
+ await applyProposalToSupabaseTaxonomy(p);
1218
+ await postJson(`/api/proposals/${encodeURIComponent(id)}/${action}`, {
1219
+ reviewer_name: reviewer,
1220
+ skip_taxonomy_update: true,
1221
+ });
1222
+ } else {
1223
+ await postJson(`/api/proposals/${encodeURIComponent(id)}/${action}`, {
1224
+ reviewer_name: reviewer,
1225
+ });
1226
+ }
1227
  // Applying should also refresh claims data next run; for now just refresh the list.
1228
  await refreshPendingProposals();
1229
  } catch (e) {