add_support_ilaas_llm_provider

#2
by lterriel - opened
Files changed (6) hide show
  1. .gitignore +5 -0
  2. app.py +172 -83
  3. prompts.py +25 -1
  4. provider.py +82 -29
  5. static/app.js +1206 -933
  6. static/index.html +0 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .idea/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ DS_Store
app.py CHANGED
@@ -5,6 +5,7 @@ file exposes a small REST API and a tiny in-memory session store. State is
5
  ephemeral and per-process; perfect for a single-user demo or HF Space.
6
  """
7
  from __future__ import annotations
 
8
 
9
  import asyncio
10
  import os
@@ -32,12 +33,14 @@ from io_utils import (
32
  )
33
  from moe import aggregate
34
  from provider import (
35
- LLMClient, PROVIDERS, BASE_URLS,
36
- CURATED_MODELS_BY_PROVIDER, test_connection_sync,
 
 
 
37
  )
38
  from tutorial import EXERCISES, prefill
39
 
40
-
41
  STATIC_DIR = APP_DIR / "static"
42
 
43
 
@@ -49,12 +52,17 @@ def _default_schema() -> AnnotationSchema:
49
  return from_preset("ud_upos_morph")
50
 
51
 
52
- ENV_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
 
 
 
 
 
53
 
54
 
55
- def _resolve_key(header_key: Optional[str]) -> str:
56
- """Prefer the per-request header (client-side key), fall back to env (shared demo key)."""
57
- return (header_key or "").strip() or ENV_API_KEY
58
 
59
 
60
  SESSION: dict[str, Any] = {
@@ -79,12 +87,12 @@ def _new_sentence(idx: int, surface_tokens: list[str], *, sentence_id: str = "",
79
  "id": sentence_id or f"s{idx + 1}",
80
  "language": language,
81
  "tokens": [{"surface": s} for s in surface_tokens],
82
- "per_model": {}, # {model -> annotation dict}
83
- "disagreements": [], # list of dis dicts
84
- "status": "pending", # pending | annotating | done | error
85
  "error": "",
86
  "n_disagreements": 0,
87
- "validated": False, # True once the user confirms this sentence as gold
88
  }
89
 
90
 
@@ -100,7 +108,7 @@ def _public_state() -> dict:
100
  "language": sess["language"],
101
  "system_prompt": sess["system_prompt"],
102
  "user_template": sess["user_template"],
103
- "has_env_key": bool(ENV_API_KEY),
104
  "models": sess["models"],
105
  "priority": sess["priority"],
106
  "temperature": sess["temperature"],
@@ -149,7 +157,7 @@ class LoadPasteReq(BaseModel):
149
  text: str
150
  tokenizer: str = "whitespace" # whitespace | newline | as_is
151
  language: str = ""
152
- split_per_line: bool = True # True -> one sentence per non-empty line
153
 
154
 
155
  class LoadExerciseReq(BaseModel):
@@ -289,6 +297,12 @@ def load_paste(req: LoadPasteReq):
289
  return _public_state()
290
 
291
 
 
 
 
 
 
 
292
  @app.post("/api/corpus/exercise")
293
  def load_exercise(req: LoadExerciseReq):
294
  if req.idx < 0 or req.idx >= len(EXERCISES):
@@ -299,7 +313,11 @@ def load_exercise(req: LoadExerciseReq):
299
  SESSION["language"] = data["language_name"]
300
  SESSION["user_template"] = data["user_template"]
301
  SESSION["system_prompt"] = data["system_prompt"]
302
- SESSION["models"] = list(data["models"])
 
 
 
 
303
  # Seed ICL pool with the example's pre-validated sandbox sentences
304
  pool = ICLPool()
305
  for ex in data["icl_examples"]:
@@ -332,7 +350,7 @@ def reset_all():
332
  SESSION["schema"] = _default_schema().to_dict()
333
  SESSION["language"] = ""
334
  SESSION["provider"] = "openrouter"
335
- SESSION["models"] = list(CURATED_MODELS_BY_PROVIDER["openrouter"][:1])
336
  SESSION["priority"] = []
337
  SESSION["temperature"] = 0.0
338
  SESSION["n_icl"] = 5
@@ -345,22 +363,68 @@ def reset_all():
345
 
346
 
347
  # --- token edit ------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
  @app.post("/api/sentence/{idx}/token/{tidx}")
350
  def update_token(idx: int, tidx: int, req: TokenUpdateReq):
351
  sents = SESSION["sentences"]
 
352
  if idx < 0 or idx >= len(sents):
353
  raise HTTPException(404, "Bad sentence idx")
354
  if tidx < 0 or tidx >= len(sents[idx]["tokens"]):
355
  raise HTTPException(404, "Bad token idx")
356
- # Preserve surface (never editable)
357
- surface = sents[idx]["tokens"][tidx]["surface"]
 
 
 
358
  new_tok = {**req.token, "surface": surface}
359
- sents[idx]["tokens"][tidx] = new_tok
360
- # Remove this token from disagreement list if it was there
361
- sents[idx]["disagreements"] = [d for d in sents[idx]["disagreements"] if d["token_idx"] != tidx]
362
- sents[idx]["n_disagreements"] = len(sents[idx]["disagreements"])
363
- return sents[idx]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
 
366
  @app.post("/api/bulk_similar")
@@ -372,7 +436,11 @@ def bulk_similar(payload: dict):
372
  "updates": {"pos": "DET", "lemma": "ὁ"},
373
  "exclude": [{"s": sidx, "t": tidx}, ...] # optional, e.g. the source token
374
  }
375
- Returns: {"affected": [{"s": sidx, "t": tidx}, ...], "state": <_public_state>}
 
 
 
 
376
  """
377
  surface = payload.get("surface")
378
  updates = payload.get("updates") or {}
@@ -398,7 +466,13 @@ def bulk_similar(payload: dict):
398
  ]
399
  sent["n_disagreements"] = len(sent["disagreements"])
400
  affected.append({"s": sidx, "t": tidx})
401
- return {"affected": affected, "state": _public_state()}
 
 
 
 
 
 
402
 
403
 
404
  @app.post("/api/sentence/{idx}/bulk")
@@ -415,39 +489,26 @@ def bulk_update(idx: int, payload: dict):
415
  for ti in idxs:
416
  if 0 <= ti < len(sents[idx]["tokens"]):
417
  sents[idx]["tokens"][ti][field] = value
418
- sents[idx]["disagreements"] = [d for d in sents[idx]["disagreements"] if not (d["token_idx"] == ti and d["field_path"] == field)]
 
419
  sents[idx]["n_disagreements"] = len(sents[idx]["disagreements"])
420
  return sents[idx]
421
 
422
 
423
  # --- ICL pool --------------------------------------------------------------
424
-
425
  @app.post("/api/sentence/{idx}/add_to_icl")
426
  def add_sentence_to_icl(idx: int):
427
- sents = SESSION["sentences"]
428
- if idx < 0 or idx >= len(sents):
429
- raise HTTPException(404, "Bad sentence idx")
430
- sent = sents[idx]
431
- schema_obj = schema_from_dict(SESSION["schema"])
432
- pool: ICLPool = SESSION["icl_pool"]
433
- ann = {
434
- "sentence_id": sent["id"],
435
- "language": sent["language"] or SESSION["language"],
436
- "tokens": sent["tokens"],
437
- }
438
- pool.add(ICLExample(
439
- language=sent["language"] or SESSION["language"] or "",
440
- schema_hash=schema_obj.hash(),
441
- tokens=[t["surface"] for t in sent["tokens"]],
442
- gold_annotation=ann,
443
- source="corrected",
444
- ))
445
- # Adding to ICL implies the user accepts this annotation as gold → mark validated.
446
- sent["validated"] = True
447
- return _public_state()
448
 
 
 
 
 
 
 
449
 
450
- @app.post("/api/sentence/{idx}/validate")
 
451
  def set_validated(idx: int, payload: dict):
452
  """payload = {value: bool}. Toggles the user-validation flag on a sentence."""
453
  sents = SESSION["sentences"]
@@ -472,10 +533,10 @@ def icl_download():
472
  # --- annotation ------------------------------------------------------------
473
 
474
  async def _annotate_sentence(sent: dict, client: LLMClient,
475
- schema: AnnotationSchema, sys_prompt: str,
476
- user_template: str, language: str,
477
- pool: ICLPool, n_icl: int, temperature: float,
478
- priority: list[str], models: list[str]) -> dict:
479
  tokens = [t["surface"] for t in sent["tokens"]]
480
  examples = pool.sample(
481
  n=int(n_icl), schema_hash=schema.hash(),
@@ -533,59 +594,74 @@ async def _annotate_sentence(sent: dict, client: LLMClient,
533
 
534
  @app.post("/api/annotate")
535
  async def annotate(
536
- req: AnnotateReq,
537
- x_api_key: Optional[str] = Header(default=None),
538
- x_openrouter_key: Optional[str] = Header(default=None), # back-compat
539
- x_llm_provider: Optional[str] = Header(default=None),
540
  ):
541
  sess = SESSION
542
  provider = (x_llm_provider or sess["provider"]).strip()
543
  if provider not in PROVIDERS:
544
  raise HTTPException(400, f"Unknown provider {provider!r}")
545
- api_key = _resolve_key(x_api_key or x_openrouter_key)
546
  if not api_key:
547
  raise HTTPException(400, f"Set your {provider} API key first.")
548
  if not sess["models"]:
549
  raise HTTPException(400, "Select at least one model.")
550
  if provider != "openrouter" and len(sess["models"]) > 1:
551
- raise HTTPException(400, f"MoE (multiple models) is only supported on OpenRouter. Pick one model for {provider}.")
 
552
  schema_obj = schema_from_dict(sess["schema"])
553
- client = LLMClient(provider=provider, api_key=api_key)
554
- pool: ICLPool = sess["icl_pool"]
555
- sents = sess["sentences"]
556
- target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
557
- coros = []
558
- for i in target_idxs:
559
- if 0 <= i < len(sents):
560
- sents[i]["status"] = "annotating"
561
- coros.append(_annotate_sentence(
562
- sents[i], client, schema_obj, sess["system_prompt"], sess["user_template"],
563
- sess["language"], pool, sess["n_icl"], sess["temperature"],
564
- sess["priority"], sess["models"],
565
- ))
566
- await asyncio.gather(*coros)
 
 
 
 
 
 
 
 
 
567
  return _public_state()
568
 
569
 
570
  @app.post("/api/annotate/token")
571
  async def annotate_one_token(
572
- payload: dict,
573
- x_api_key: Optional[str] = Header(default=None),
574
- x_openrouter_key: Optional[str] = Header(default=None),
575
- x_llm_provider: Optional[str] = Header(default=None),
576
  ):
577
  """Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
578
  sess = SESSION
579
  provider = (x_llm_provider or sess["provider"]).strip()
 
580
  if provider not in PROVIDERS:
581
  raise HTTPException(400, f"Unknown provider {provider!r}")
582
- api_key = _resolve_key(x_api_key or x_openrouter_key)
583
  if not api_key:
584
  raise HTTPException(400, f"Set your {provider} API key first.")
585
  idx = int(payload["sent"])
586
  tidx = int(payload["tok"])
587
  model = str(payload["model"])
 
 
588
  sent = sess["sentences"][idx]
 
 
589
  schema = schema_from_dict(sess["schema"])
590
  tokens = [t["surface"] for t in sent["tokens"]]
591
  pool: ICLPool = sess["icl_pool"]
@@ -595,13 +671,26 @@ async def annotate_one_token(
595
  language=sess["language"] or sent["language"], sentence_id=sent["id"],
596
  few_shot_examples=examples,
597
  ) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
598
- client = LLMClient(provider=provider, api_key=api_key)
599
- result = await client.annotate_one(
600
- system=sess["system_prompt"], user=rendered_user,
601
- schema=schema, model=model, temperature=float(sess["temperature"]),
602
- )
 
 
 
 
 
 
 
 
 
 
 
603
  if not result.ok or not result.annotation:
604
  raise HTTPException(502, f"{model} failed: {result.error}")
 
 
605
  # update only the targeted token
606
  new_tok = result.annotation["tokens"][tidx]
607
  new_tok["surface"] = tokens[tidx]
@@ -668,7 +757,7 @@ def validate_sentence(idx: int):
668
 
669
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
670
 
671
-
672
  if __name__ == "__main__":
673
  import uvicorn
 
674
  uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
 
5
  ephemeral and per-process; perfect for a single-user demo or HF Space.
6
  """
7
  from __future__ import annotations
8
+ from copy import deepcopy
9
 
10
  import asyncio
11
  import os
 
33
  )
34
  from moe import aggregate
35
  from provider import (
36
+ LLMClient,
37
+ PROVIDERS,
38
+ BASE_URLS,
39
+ CURATED_MODELS_BY_PROVIDER,
40
+ test_connection_sync
41
  )
42
  from tutorial import EXERCISES, prefill
43
 
 
44
  STATIC_DIR = APP_DIR / "static"
45
 
46
 
 
52
  return from_preset("ud_upos_morph")
53
 
54
 
55
+ ENV_API_KEYS = {
56
+ "openrouter": os.environ.get("OPENROUTER_API_KEY", ""),
57
+ "mistral": os.environ.get("MISTRAL_API_KEY", ""),
58
+ "openai": os.environ.get("OPENAI_API_KEY", ""),
59
+ "ilaas": os.environ.get("ILAAS_API_KEY", ""),
60
+ }
61
 
62
 
63
+ def _resolve_key(provider: str, header_key: Optional[str]) -> str:
64
+ """Prefer the per-request header, fall back to provider-specific env key."""
65
+ return (header_key or "").strip() or ENV_API_KEYS.get(provider, "")
66
 
67
 
68
  SESSION: dict[str, Any] = {
 
87
  "id": sentence_id or f"s{idx + 1}",
88
  "language": language,
89
  "tokens": [{"surface": s} for s in surface_tokens],
90
+ "per_model": {}, # {model -> annotation dict}
91
+ "disagreements": [], # list of dis dicts
92
+ "status": "pending", # pending | annotating | done | error
93
  "error": "",
94
  "n_disagreements": 0,
95
+ "validated": False, # True once the user confirms this sentence as gold
96
  }
97
 
98
 
 
108
  "language": sess["language"],
109
  "system_prompt": sess["system_prompt"],
110
  "user_template": sess["user_template"],
111
+ "has_env_key": bool(ENV_API_KEYS.get(sess["provider"], "")),
112
  "models": sess["models"],
113
  "priority": sess["priority"],
114
  "temperature": sess["temperature"],
 
157
  text: str
158
  tokenizer: str = "whitespace" # whitespace | newline | as_is
159
  language: str = ""
160
+ split_per_line: bool = True # True -> one sentence per non-empty line
161
 
162
 
163
  class LoadExerciseReq(BaseModel):
 
297
  return _public_state()
298
 
299
 
300
+ def _default_models_for_provider(provider: str) -> list[str]:
301
+ """Helper to get the default model(s) for a provider, used when switching providers."""
302
+ curated = CURATED_MODELS_BY_PROVIDER.get(provider) or []
303
+ return list(curated[:1])
304
+
305
+
306
  @app.post("/api/corpus/exercise")
307
  def load_exercise(req: LoadExerciseReq):
308
  if req.idx < 0 or req.idx >= len(EXERCISES):
 
313
  SESSION["language"] = data["language_name"]
314
  SESSION["user_template"] = data["user_template"]
315
  SESSION["system_prompt"] = data["system_prompt"]
316
+ # Exercise presets may contain OpenRouter slugs. Keep them only when using OpenRouter.
317
+ if SESSION["provider"] == "openrouter":
318
+ SESSION["models"] = list(data["models"])
319
+ else:
320
+ SESSION["models"] = _default_models_for_provider(SESSION["provider"])
321
  # Seed ICL pool with the example's pre-validated sandbox sentences
322
  pool = ICLPool()
323
  for ex in data["icl_examples"]:
 
350
  SESSION["schema"] = _default_schema().to_dict()
351
  SESSION["language"] = ""
352
  SESSION["provider"] = "openrouter"
353
+ SESSION["models"] = _default_models_for_provider("openrouter")
354
  SESSION["priority"] = []
355
  SESSION["temperature"] = 0.0
356
  SESSION["n_icl"] = 5
 
363
 
364
 
365
  # --- token edit ------------------------------------------------------------
366
+ def _add_or_update_sentence_in_icl(idx: int) -> str:
367
+ sents = SESSION["sentences"]
368
+ if idx < 0 or idx >= len(sents):
369
+ raise HTTPException(404, "Bad sentence idx")
370
+ sent = sents[idx]
371
+ schema_obj = schema_from_dict(SESSION["schema"])
372
+ pool: ICLPool = SESSION["icl_pool"]
373
+ tokens_snapshot = deepcopy(sent["tokens"])
374
+ ann = {
375
+ "sentence_id": sent["id"],
376
+ "language": sent["language"] or SESSION["language"],
377
+ "tokens": tokens_snapshot,
378
+ }
379
+
380
+ result = pool.add(ICLExample(
381
+ language=sent["language"] or SESSION["language"] or "",
382
+ schema_hash=schema_obj.hash(),
383
+ tokens=[t["surface"] for t in tokens_snapshot],
384
+ gold_annotation=ann,
385
+ source="corrected",
386
+ ))
387
+
388
+ sent["validated"] = True
389
+ return result
390
+
391
 
392
  @app.post("/api/sentence/{idx}/token/{tidx}")
393
  def update_token(idx: int, tidx: int, req: TokenUpdateReq):
394
  sents = SESSION["sentences"]
395
+
396
  if idx < 0 or idx >= len(sents):
397
  raise HTTPException(404, "Bad sentence idx")
398
  if tidx < 0 or tidx >= len(sents[idx]["tokens"]):
399
  raise HTTPException(404, "Bad token idx")
400
+
401
+ sent = sents[idx]
402
+ was_validated = bool(sent.get("validated"))
403
+
404
+ surface = sent["tokens"][tidx]["surface"]
405
  new_tok = {**req.token, "surface": surface}
406
+ sent["tokens"][tidx] = new_tok
407
+
408
+ sent["disagreements"] = [
409
+ d for d in sent["disagreements"]
410
+ if d["token_idx"] != tidx
411
+ ]
412
+ sent["n_disagreements"] = len(sent["disagreements"])
413
+
414
+ icl_result = None
415
+
416
+ # If sentence in ICL pool already, update it. If not, add it. This way we keep the pool in sync with user corrections.
417
+ if was_validated:
418
+ icl_result = _add_or_update_sentence_in_icl(idx)
419
+
420
+ state = _public_state()
421
+ state["updated_sentence_idx"] = idx
422
+ state["icl_add_result"] = icl_result
423
+ state["icl_duplicate"] = icl_result == "unchanged"
424
+ state["icl_updated"] = icl_result == "updated"
425
+ state["icl_inserted"] = icl_result == "inserted"
426
+
427
+ return state
428
 
429
 
430
  @app.post("/api/bulk_similar")
 
436
  "updates": {"pos": "DET", "lemma": "ὁ"},
437
  "exclude": [{"s": sidx, "t": tidx}, ...] # optional, e.g. the source token
438
  }
439
+ Returns:
440
+ {
441
+ "affected": [{"s": sidx, "t": tidx}, ...],
442
+ "sentences": [{"idx": sidx, "sentence": {...}}, ...]
443
+ }
444
  """
445
  surface = payload.get("surface")
446
  updates = payload.get("updates") or {}
 
466
  ]
467
  sent["n_disagreements"] = len(sent["disagreements"])
468
  affected.append({"s": sidx, "t": tidx})
469
+ return {
470
+ "affected": affected,
471
+ "sentences": [
472
+ {"idx": i, "sentence": SESSION["sentences"][i]}
473
+ for i in sorted({a["s"] for a in affected})
474
+ ],
475
+ }
476
 
477
 
478
  @app.post("/api/sentence/{idx}/bulk")
 
489
  for ti in idxs:
490
  if 0 <= ti < len(sents[idx]["tokens"]):
491
  sents[idx]["tokens"][ti][field] = value
492
+ sents[idx]["disagreements"] = [d for d in sents[idx]["disagreements"] if
493
+ not (d["token_idx"] == ti and d["field_path"] == field)]
494
  sents[idx]["n_disagreements"] = len(sents[idx]["disagreements"])
495
  return sents[idx]
496
 
497
 
498
  # --- ICL pool --------------------------------------------------------------
 
499
  @app.post("/api/sentence/{idx}/add_to_icl")
500
  def add_sentence_to_icl(idx: int):
501
+ result = _add_or_update_sentence_in_icl(idx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
 
503
+ state = _public_state()
504
+ state["icl_add_result"] = result
505
+ state["icl_duplicate"] = result == "unchanged"
506
+ state["icl_updated"] = result == "updated"
507
+ state["icl_inserted"] = result == "inserted"
508
+ return state
509
 
510
+
511
+ @app.post("/api/sentence/{idx}/sent_score")
512
  def set_validated(idx: int, payload: dict):
513
  """payload = {value: bool}. Toggles the user-validation flag on a sentence."""
514
  sents = SESSION["sentences"]
 
533
  # --- annotation ------------------------------------------------------------
534
 
535
  async def _annotate_sentence(sent: dict, client: LLMClient,
536
+ schema: AnnotationSchema, sys_prompt: str,
537
+ user_template: str, language: str,
538
+ pool: ICLPool, n_icl: int, temperature: float,
539
+ priority: list[str], models: list[str]) -> dict:
540
  tokens = [t["surface"] for t in sent["tokens"]]
541
  examples = pool.sample(
542
  n=int(n_icl), schema_hash=schema.hash(),
 
594
 
595
  @app.post("/api/annotate")
596
  async def annotate(
597
+ req: AnnotateReq,
598
+ x_api_key: Optional[str] = Header(default=None),
599
+ x_openrouter_key: Optional[str] = Header(default=None), # back-compat
600
+ x_llm_provider: Optional[str] = Header(default=None),
601
  ):
602
  sess = SESSION
603
  provider = (x_llm_provider or sess["provider"]).strip()
604
  if provider not in PROVIDERS:
605
  raise HTTPException(400, f"Unknown provider {provider!r}")
606
+ api_key = _resolve_key(provider, x_api_key or x_openrouter_key)
607
  if not api_key:
608
  raise HTTPException(400, f"Set your {provider} API key first.")
609
  if not sess["models"]:
610
  raise HTTPException(400, "Select at least one model.")
611
  if provider != "openrouter" and len(sess["models"]) > 1:
612
+ raise HTTPException(400,
613
+ f"MoE (multiple models) is only supported on OpenRouter. Pick one model for {provider}.")
614
  schema_obj = schema_from_dict(sess["schema"])
615
+ if provider != "openrouter":
616
+ allowed = set(CURATED_MODELS_BY_PROVIDER.get(provider) or [])
617
+ unknown = [m for m in sess["models"] if m not in allowed]
618
+ if unknown:
619
+ raise HTTPException(
620
+ 400,
621
+ f"Model(s) not available for provider {provider}: {unknown}. "
622
+ f"Pick one of: {sorted(allowed)}"
623
+ )
624
+ async with LLMClient(provider=provider, api_key=api_key) as client:
625
+ pool: ICLPool = sess["icl_pool"]
626
+ sents = sess["sentences"]
627
+ target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
628
+ coros = []
629
+ for i in target_idxs:
630
+ if 0 <= i < len(sents):
631
+ sents[i]["status"] = "annotating"
632
+ coros.append(_annotate_sentence(
633
+ sents[i], client, schema_obj, sess["system_prompt"], sess["user_template"],
634
+ sess["language"], pool, sess["n_icl"], sess["temperature"],
635
+ sess["priority"], sess["models"],
636
+ ))
637
+ await asyncio.gather(*coros)
638
  return _public_state()
639
 
640
 
641
  @app.post("/api/annotate/token")
642
  async def annotate_one_token(
643
+ payload: dict,
644
+ x_api_key: Optional[str] = Header(default=None),
645
+ x_openrouter_key: Optional[str] = Header(default=None),
646
+ x_llm_provider: Optional[str] = Header(default=None),
647
  ):
648
  """Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
649
  sess = SESSION
650
  provider = (x_llm_provider or sess["provider"]).strip()
651
+
652
  if provider not in PROVIDERS:
653
  raise HTTPException(400, f"Unknown provider {provider!r}")
654
+ api_key = _resolve_key(provider, x_api_key or x_openrouter_key)
655
  if not api_key:
656
  raise HTTPException(400, f"Set your {provider} API key first.")
657
  idx = int(payload["sent"])
658
  tidx = int(payload["tok"])
659
  model = str(payload["model"])
660
+ if idx < 0 or idx >= len(sess["sentences"]):
661
+ raise HTTPException(404, "Bad sentence idx")
662
  sent = sess["sentences"][idx]
663
+ if tidx < 0 or tidx >= len(sent["tokens"]):
664
+ raise HTTPException(404, "Bad token idx")
665
  schema = schema_from_dict(sess["schema"])
666
  tokens = [t["surface"] for t in sent["tokens"]]
667
  pool: ICLPool = sess["icl_pool"]
 
671
  language=sess["language"] or sent["language"], sentence_id=sent["id"],
672
  few_shot_examples=examples,
673
  ) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
674
+ if provider != "openrouter":
675
+ allowed = set(CURATED_MODELS_BY_PROVIDER.get(provider) or [])
676
+ if model not in allowed:
677
+ raise HTTPException(
678
+ 400,
679
+ f"Model {model!r} is not available for provider {provider}. "
680
+ f"Pick one of: {sorted(allowed)}"
681
+ )
682
+ async with LLMClient(provider=provider, api_key=api_key) as client:
683
+ result = await client.annotate_one(
684
+ system=sess["system_prompt"],
685
+ user=rendered_user,
686
+ schema=schema,
687
+ model=model,
688
+ temperature=float(sess["temperature"]),
689
+ )
690
  if not result.ok or not result.annotation:
691
  raise HTTPException(502, f"{model} failed: {result.error}")
692
+ if tidx >= len(result.annotation.get("tokens", [])):
693
+ raise HTTPException(502, f"{model} returned too few tokens.")
694
  # update only the targeted token
695
  new_tok = result.annotation["tokens"][tidx]
696
  new_tok["surface"] = tokens[tidx]
 
757
 
758
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
759
 
 
760
  if __name__ == "__main__":
761
  import uvicorn
762
+
763
  uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
prompts.py CHANGED
@@ -5,6 +5,7 @@ written material. ICLPool keeps a session-scoped, filterable bank of validated
5
  or corrected examples.
6
  """
7
  from __future__ import annotations
 
8
 
9
  import json
10
  import random
@@ -30,6 +31,8 @@ class ICLExample:
30
  note: str = ""
31
 
32
 
 
 
33
  @dataclass
34
  class ICLPool:
35
  """Session-scoped pool of in-context examples.
@@ -40,9 +43,30 @@ class ICLPool:
40
  entries: list[ICLExample] = field(default_factory=list)
41
  version: int = 0
42
 
43
- def add(self, ex: ICLExample) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  self.entries.append(ex)
45
  self.version += 1
 
46
 
47
  def filter(self, language: str = "", schema_hash: str = "") -> list[ICLExample]:
48
  out = self.entries
 
5
  or corrected examples.
6
  """
7
  from __future__ import annotations
8
+ from copy import deepcopy
9
 
10
  import json
11
  import random
 
31
  note: str = ""
32
 
33
 
34
+
35
+
36
  @dataclass
37
  class ICLPool:
38
  """Session-scoped pool of in-context examples.
 
43
  entries: list[ICLExample] = field(default_factory=list)
44
  version: int = 0
45
 
46
+ def _key(self, ex: ICLExample) -> tuple[str, str, tuple[str, ...]]:
47
+ return (
48
+ ex.language or "",
49
+ ex.schema_hash or "",
50
+ tuple(ex.tokens or []),
51
+ )
52
+
53
+ def _same_content(self, a: ICLExample, b: ICLExample) -> bool:
54
+ return a.gold_annotation == b.gold_annotation
55
+
56
+ def add(self, ex: ICLExample) -> str:
57
+ ex = deepcopy(ex)
58
+ key = self._key(ex)
59
+
60
+ for i, existing in enumerate(self.entries):
61
+ if self._key(existing) == key:
62
+ if self._same_content(existing, ex):
63
+ return "unchanged"
64
+ self.entries[i] = ex
65
+ self.version += 1
66
+ return "updated"
67
  self.entries.append(ex)
68
  self.version += 1
69
+ return "inserted"
70
 
71
  def filter(self, language: str = "", schema_hash: str = "") -> list[ICLExample]:
72
  out = self.entries
provider.py CHANGED
@@ -4,6 +4,7 @@ Supports three OpenAI-compatible providers via parametric base URL:
4
  - openrouter : https://openrouter.ai/api/v1 (MoE: many models behind one key)
5
  - mistral : https://api.mistral.ai/v1
6
  - openai : https://api.openai.com/v1
 
7
 
8
  All three accept the same OpenAI Chat Completions request shape, including
9
  `response_format` (json_schema strict on OpenAI; json_object on Mistral; varies
@@ -24,14 +25,16 @@ from prompts import VALIDATION_RETRY
24
 
25
  DEFAULT_TIMEOUT = 60.0
26
 
27
- PROVIDERS = ("openrouter", "mistral", "openai")
28
 
29
  BASE_URLS = {
30
  "openrouter": "https://openrouter.ai/api/v1",
31
- "mistral": "https://api.mistral.ai/v1",
32
- "openai": "https://api.openai.com/v1",
 
33
  }
34
 
 
 
35
  CURATED_MODELS_BY_PROVIDER: dict[str, list[str]] = {
36
  "openrouter": [
37
  "openai/gpt-oss-20b:free",
@@ -55,6 +58,14 @@ CURATED_MODELS_BY_PROVIDER: dict[str, list[str]] = {
55
  "gpt-5-2025-08-07",
56
  "gpt-4o-mini-2024-07-18",
57
  ],
 
 
 
 
 
 
 
 
58
  }
59
 
60
  # Back-compat alias used by other modules
@@ -81,33 +92,55 @@ def _build_headers(provider: str, api_key: str) -> dict:
81
  h["X-Title"] = "LREC2026 LLM-as-Annotator"
82
  return h
83
 
84
-
85
  class LLMClient:
86
- def __init__(self, provider: str, api_key: str):
87
  if provider not in BASE_URLS:
88
- raise ValueError(f"Unknown provider {provider!r}; expected one of {PROVIDERS}")
89
  self.provider = provider
90
  self.api_key = api_key
91
  self.base_url = BASE_URLS[provider]
92
  self.endpoint = self.base_url + "/chat/completions"
93
  self.headers = _build_headers(provider, api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  async def annotate_one(
96
- self,
97
- *,
98
- system: str,
99
- user: str,
100
- schema: AnnotationSchema,
101
- model: str,
102
- temperature: float = 0.0,
103
- timeout: float = DEFAULT_TIMEOUT,
104
  ) -> ModelResult:
105
  """Call one model, validate JSON. One retry on schema-validation failure."""
 
106
  json_schema = to_json_schema(schema)
107
  start = time.time()
108
  msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
109
  try:
110
- async with httpx.AsyncClient(timeout=timeout) as client:
 
 
 
 
 
111
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
112
  ann, err = self._parse_and_validate(raw_text, schema)
113
  if err:
@@ -117,20 +150,28 @@ class LLMClient:
117
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
118
  ann, err = self._parse_and_validate(raw_text, schema)
119
  if err:
120
- return ModelResult(model=model, ok=False, annotation=None, latency_s=time.time() - start, error=err, raw=raw_text)
 
 
 
 
121
  return ModelResult(model=model, ok=True, annotation=ann, latency_s=time.time() - start, raw=raw_text)
 
 
 
122
  except Exception as e:
 
123
  return ModelResult(model=model, ok=False, annotation=None, latency_s=time.time() - start, error=str(e))
124
 
125
  async def annotate_many(
126
- self,
127
- *,
128
- models: list[str],
129
- system: str,
130
- user: str,
131
- schema: AnnotationSchema,
132
- temperature: float = 0.0,
133
- timeout: float = DEFAULT_TIMEOUT,
134
  ) -> list[ModelResult]:
135
  coros = [
136
  self.annotate_one(
@@ -140,26 +181,38 @@ class LLMClient:
140
  ]
141
  return await asyncio.gather(*coros)
142
 
143
- async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str, temperature: float) -> str:
 
144
  # Strict json_schema works on OpenAI and most OpenRouter models. For Mistral and
145
  # for some open-source models routed via OpenRouter, fall back to json_object.
146
- if self.provider == "mistral":
147
  payload = {
148
- "model": model, "messages": msgs, "temperature": temperature,
 
 
149
  "response_format": {"type": "json_object"},
150
  }
151
  else:
152
  payload = {
153
- "model": model, "messages": msgs, "temperature": temperature,
 
 
154
  "response_format": {
155
  "type": "json_schema",
156
- "json_schema": {"name": "annotation", "strict": True, "schema": json_schema},
 
 
 
 
157
  },
158
  }
159
  resp = await client.post(self.endpoint, headers=self.headers, json=payload)
160
  if resp.status_code >= 400:
161
  payload["response_format"] = {"type": "json_object"}
162
  resp = await client.post(self.endpoint, headers=self.headers, json=payload)
 
 
 
163
  resp.raise_for_status()
164
  data = resp.json()
165
  return data["choices"][0]["message"]["content"] or ""
 
4
  - openrouter : https://openrouter.ai/api/v1 (MoE: many models behind one key)
5
  - mistral : https://api.mistral.ai/v1
6
  - openai : https://api.openai.com/v1
7
+ - ilaas : https://llm.ilaas.fr/v1 (documentation: https://www.ilaas.fr/services-inference/)
8
 
9
  All three accept the same OpenAI Chat Completions request shape, including
10
  `response_format` (json_schema strict on OpenAI; json_object on Mistral; varies
 
25
 
26
  DEFAULT_TIMEOUT = 60.0
27
 
 
28
 
29
  BASE_URLS = {
30
  "openrouter": "https://openrouter.ai/api/v1",
31
+ "mistral": "https://api.mistral.ai/v1",
32
+ "openai": "https://api.openai.com/v1",
33
+ "ilaas": "https://llm.ilaas.fr/v1",
34
  }
35
 
36
+ PROVIDERS = tuple(BASE_URLS.keys())
37
+
38
  CURATED_MODELS_BY_PROVIDER: dict[str, list[str]] = {
39
  "openrouter": [
40
  "openai/gpt-oss-20b:free",
 
58
  "gpt-5-2025-08-07",
59
  "gpt-4o-mini-2024-07-18",
60
  ],
61
+ "ilaas": [
62
+ "gemma-4-31b",
63
+ "gpt-oss-120b",
64
+ "llama-3.1-8b",
65
+ "llama-3.3-70b",
66
+ "qwen-3.6-35b-instruct",
67
+ "mistral-small-3.2-24b",
68
+ ]
69
  }
70
 
71
  # Back-compat alias used by other modules
 
92
  h["X-Title"] = "LREC2026 LLM-as-Annotator"
93
  return h
94
 
 
95
  class LLMClient:
96
+ def __init__(self, provider: str, api_key: str, timeout: float = DEFAULT_TIMEOUT):
97
  if provider not in BASE_URLS:
98
+ raise ValueError(f"Unknown provider {provider!r}; expected one of {tuple(BASE_URLS)}")
99
  self.provider = provider
100
  self.api_key = api_key
101
  self.base_url = BASE_URLS[provider]
102
  self.endpoint = self.base_url + "/chat/completions"
103
  self.headers = _build_headers(provider, api_key)
104
+ self.timeout = timeout
105
+ self._client: httpx.AsyncClient | None = None
106
+
107
+ async def __aenter__(self):
108
+ self._client = httpx.AsyncClient(
109
+ timeout=self.timeout,
110
+ limits=httpx.Limits(
111
+ max_connections=20,
112
+ max_keepalive_connections=10,
113
+ ),
114
+ )
115
+ return self
116
+
117
+ async def __aexit__(self, exc_type, exc, tb):
118
+ if self._client:
119
+ await self._client.aclose()
120
+ self._client = None
121
 
122
  async def annotate_one(
123
+ self,
124
+ *,
125
+ system: str,
126
+ user: str,
127
+ schema: AnnotationSchema,
128
+ model: str,
129
+ temperature: float = 0.0,
130
+ timeout: float = DEFAULT_TIMEOUT,
131
  ) -> ModelResult:
132
  """Call one model, validate JSON. One retry on schema-validation failure."""
133
+ print(f"[LLM] start provider={self.provider} model={model}")
134
  json_schema = to_json_schema(schema)
135
  start = time.time()
136
  msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
137
  try:
138
+ client = self._client
139
+ close_after = False
140
+ if client is None:
141
+ client = httpx.AsyncClient(timeout=timeout)
142
+ close_after = True
143
+ try:
144
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
145
  ann, err = self._parse_and_validate(raw_text, schema)
146
  if err:
 
150
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
151
  ann, err = self._parse_and_validate(raw_text, schema)
152
  if err:
153
+ print(
154
+ f"[LLM] error provider={self.provider} model={model} latency={time.time() - start:.2f}s error={e}")
155
+ return ModelResult(model=model, ok=False, annotation=None, latency_s=time.time() - start, error=err,
156
+ raw=raw_text)
157
+ print(f"[LLM] done provider={self.provider} model={model} latency={time.time() - start:.2f}s")
158
  return ModelResult(model=model, ok=True, annotation=ann, latency_s=time.time() - start, raw=raw_text)
159
+ finally:
160
+ if close_after:
161
+ await client.aclose()
162
  except Exception as e:
163
+ print(f"[LLM] error provider={self.provider} model={model} latency={time.time() - start:.2f}s error={e}")
164
  return ModelResult(model=model, ok=False, annotation=None, latency_s=time.time() - start, error=str(e))
165
 
166
  async def annotate_many(
167
+ self,
168
+ *,
169
+ models: list[str],
170
+ system: str,
171
+ user: str,
172
+ schema: AnnotationSchema,
173
+ temperature: float = 0.0,
174
+ timeout: float = DEFAULT_TIMEOUT,
175
  ) -> list[ModelResult]:
176
  coros = [
177
  self.annotate_one(
 
181
  ]
182
  return await asyncio.gather(*coros)
183
 
184
+ async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str,
185
+ temperature: float) -> str:
186
  # Strict json_schema works on OpenAI and most OpenRouter models. For Mistral and
187
  # for some open-source models routed via OpenRouter, fall back to json_object.
188
+ if self.provider in {"mistral", "ilaas"}:
189
  payload = {
190
+ "model": model,
191
+ "messages": msgs,
192
+ "temperature": temperature,
193
  "response_format": {"type": "json_object"},
194
  }
195
  else:
196
  payload = {
197
+ "model": model,
198
+ "messages": msgs,
199
+ "temperature": temperature,
200
  "response_format": {
201
  "type": "json_schema",
202
+ "json_schema": {
203
+ "name": "annotation",
204
+ "strict": True,
205
+ "schema": json_schema,
206
+ },
207
  },
208
  }
209
  resp = await client.post(self.endpoint, headers=self.headers, json=payload)
210
  if resp.status_code >= 400:
211
  payload["response_format"] = {"type": "json_object"}
212
  resp = await client.post(self.endpoint, headers=self.headers, json=payload)
213
+ if resp.status_code >= 400:
214
+ payload.pop("response_format", None)
215
+ resp = await client.post(self.endpoint, headers=self.headers, json=payload)
216
  resp.raise_for_status()
217
  data = resp.json()
218
  return data["choices"][0]["message"]["content"] or ""
static/app.js CHANGED
@@ -1,948 +1,1221 @@
1
  // LREC 2026 — LLM Annotator front-end logic (Alpine.js)
2
 
3
  function annotator() {
4
- return {
5
- // ----------- state -----------
6
- paperLink: 'https://aclanthology.org/2026.loreslm-1.28/',
7
- loading: false,
8
- progressText: 'Annotating…',
9
- modal: null,
10
- cheatsheetHtml: '',
11
- toasts: [],
12
- nextToastId: 1,
13
- focus: { sent: null, tok: null },
14
- selection: new Set(),
15
- ctxMenu: { open: false, x: 0, y: 0, s: null, t: null },
16
- guideDismissed: false,
17
- moeBannerDismissed: false,
18
- moeHintDismissed: false,
19
- // Per-provider client-side keys; persisted in sessionStorage only
20
- localKeys: { openrouter: '', mistral: '', openai: '' },
21
-
22
- state: {
23
- schema: null,
24
- schema_hash: '',
25
- json_schema: {},
26
- language: '',
27
- system_prompt: '',
28
- user_template: '',
29
- has_env_key: false,
30
- provider: 'openrouter',
31
- providers: ['openrouter', 'mistral', 'openai'],
32
- curated_models_by_provider: {},
33
- models: [],
34
- priority: [],
35
- temperature: 0,
36
- n_icl: 5,
37
- icl_pool: { version: 0, size: 0, entries: [] },
38
- sentences: [],
39
- presets: [],
40
- curated_models: [],
41
- aggregators: [],
42
- exercises: [],
43
- },
44
-
45
- editor: {
46
- sidx: null, tidx: null,
47
- tok: null,
48
- original: null, // snapshot at modal-open, used to diff field changes
49
- perModel: {},
50
- disagreementCells: [],
51
- search: {},
52
- filtered: {},
53
- autoAdvance: true,
54
- propagateToSimilar: false,
55
- },
56
-
57
- taskEditor: { json: '' },
58
- modelEditor: { custom: '', priority: '' },
59
- keyEditor: { value: '', testing: false, result: '', ok: false },
60
- pasteEditor: {
61
- text: '',
62
- tokenizer: 'whitespace',
63
- language: '',
64
- presetKey: 'ud_upos_morph',
65
- customTaskName: 'My custom task',
66
- customTagInput: '',
67
- customTags: [],
68
- includeNone: true,
69
- includeConfidence: true,
70
- includeComment: false,
71
- },
72
- advEditor: { system_prompt: '', user_template: '', n_icl: 5, temperature: 0 },
73
- bulkEditor: { field: '', value: '' },
74
-
75
- // ----------- derived -----------
76
- get schema() { return this.state.schema; },
77
- get schemaFields() {
78
- const f = (this.state.schema && this.state.schema.fields) || [];
79
- return f;
80
- },
81
- get totalTokens() {
82
- return this.state.sentences.reduce((a, s) => a + s.tokens.length, 0);
83
- },
84
- get totalDisagreements() {
85
- return this.state.sentences.reduce((a, s) => a + (s.n_disagreements || 0), 0);
86
- },
87
- // ----------- key helpers (per-provider) -----------
88
- get localKey() { return this.localKeys[this.state.provider] || ''; },
89
- setLocalKey(value) {
90
- const p = this.state.provider;
91
- this.localKeys = { ...this.localKeys, [p]: value };
92
- try { sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys)); } catch (e) {}
93
- },
94
- get hasKey() {
95
- // env key is OpenRouter-only on the server side
96
- const envOk = this.state.provider === 'openrouter' && !!this.state.has_env_key;
97
- return !!this.localKey || envOk;
98
- },
99
- get canRun() {
100
- return this.hasKey && this.state.models.length > 0 && this.state.sentences.length > 0;
101
- },
102
- keyHeaders() {
103
- const h = { 'X-LLM-Provider': this.state.provider };
104
- if (this.localKey) h['X-API-Key'] = this.localKey;
105
- return h;
106
- },
107
-
108
- // ----------- init -----------
109
- async init() {
110
- this.guideDismissed = localStorage.getItem('guideDismissed') === '1';
111
- this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1';
112
- this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1';
113
- // Load per-provider keys; migrate legacy single-key key if present
114
- try {
115
- const raw = sessionStorage.getItem('llm_keys');
116
- if (raw) this.localKeys = { openrouter: '', mistral: '', openai: '', ...JSON.parse(raw) };
117
- const legacy = sessionStorage.getItem('openrouter_key');
118
- if (legacy && !this.localKeys.openrouter) {
119
- this.localKeys = { ...this.localKeys, openrouter: legacy };
120
- sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys));
121
- sessionStorage.removeItem('openrouter_key');
122
- }
123
- } catch (e) {}
124
- await this.refresh();
125
- try {
126
- const r = await fetch('/api/cheatsheet');
127
- const txt = await r.text();
128
- this.cheatsheetHtml = this.markdownToHtml(txt);
129
- } catch (e) {}
130
- // sync editor mirrors
131
- this.taskEditor.json = JSON.stringify(this.state.schema, null, 2);
132
- this.modelEditor.priority = this.state.priority.join(', ');
133
- this.advEditor.system_prompt = this.state.system_prompt;
134
- this.advEditor.user_template = this.state.user_template;
135
- this.advEditor.n_icl = this.state.n_icl;
136
- this.advEditor.temperature = this.state.temperature;
137
- window.addEventListener('keydown', (e) => this.globalKey(e));
138
- // persist dismissals
139
- this.$watch?.('moeBannerDismissed', v => localStorage.setItem('moeBannerDismissed', v ? '1' : '0'));
140
- this.$watch?.('moeHintDismissed', v => localStorage.setItem('moeHintDismissed', v ? '1' : '0'));
141
- },
142
-
143
- // ----------- contextual guide -----------
144
- get guide() {
145
- const s = this.state;
146
- if (s.sentences.length === 0) {
147
- return {
148
- step: 1, icon: '📜', title: 'Load a corpus to start',
149
- body: 'Pick a sandbox example in the left sidebar — Greek, Armenian or Syriac. They come with a task preset, a validated tagset, and 3–5 pre-loaded ICL examples (visible in the toolbar: <strong>ICL pool · v3 · 5 ex</strong>).',
150
- actions: [
151
- { label: '📘 Try Armenian (HYE)', handler: 'loadExercise', arg: 1 },
152
- { label: 'Paste my own text', handler: 'modal', arg: 'paste' },
153
- ],
154
- };
155
- }
156
- if (!this.hasKey) {
157
- return {
158
- step: 2, icon: '🔑', title: 'Add your OpenRouter API key',
159
- body: 'One key gives you access to Claude, GPT, Mistral, Llama, Qwen, DeepSeek and more. The key is kept <strong>in this browser tab only</strong> (sessionStorage) and never sent to or stored on the server — you can wipe it with the <strong>Clear key</strong> button at any time.',
160
- actions: [
161
- { label: 'Add API key', handler: 'modal', arg: 'key' },
162
- { label: 'Get a key →', handler: 'openExternal', arg: 'https://openrouter.ai/keys' },
163
- ],
164
- };
165
- }
166
- const anyDone = s.sentences.some(x => x.status === 'done');
167
- const anyPending = s.sentences.some(x => x.status === 'pending');
168
- const totalDis = this.totalDisagreements;
169
- const lastWasMoE = s.sentences.some(x => Object.keys(x.per_model || {}).length >= 2);
170
-
171
- if (!anyDone) {
172
- const moeNote = s.models.length >= 2
173
- ? `<strong>MoE is ON</strong> — your ${s.models.length} models will be called in parallel and their answers voted token-by-token.`
174
- : `Single model mode. To enable <strong>Mixture-of-Experts</strong> (parallel models + per-token vote), add a 2nd model.`;
175
- return {
176
- step: 3, icon: '▶️', title: 'Run the first annotation',
177
- body: `Click <strong>Annotate all</strong> in the toolbar. The ${s.icl_pool.size} ICL examples already in the pool will be sent as few-shot context. ${moeNote}`,
178
- actions: [
179
- { label: '▶ Annotate all', handler: 'annotateAll' },
180
- { label: 'Add a 2nd model (MoE)', handler: 'modal', arg: 'models', show: s.models.length < 2 },
181
- ].filter(a => a.show !== false),
182
- };
183
- }
184
- if (totalDis > 0) {
185
- const moeMsg = lastWasMoE
186
- ? `Each ⚠ amber token has at least one field where your models disagreed. Click it to see <em>which model said what</em> and pick the right answer (or click <kbd>adopt</kbd> next to one model).`
187
- : `Click a token to edit it: change its tag, lemma, or any field. With keyboard: <kbd>e</kbd> to edit, <kbd>↵</kbd> to save & auto-advance to the next ⚠.`;
188
- return {
189
- step: 4, icon: '⚠', title: `Review ${totalDis} disagreement${totalDis !== 1 ? 's' : ''}`,
190
- body: moeMsg,
191
- actions: [
192
- { label: 'Open first ⚠', handler: 'jumpToFirstDisagreement' },
193
- ],
194
- };
195
- }
196
- if (s.icl_pool.entries.filter(e => e.source === 'corrected').length === 0 && anyDone) {
197
- return {
198
- step: 5, icon: '📥', title: 'Feed corrections back to ICL',
199
- body: 'Your sentences look consensual. To bootstrap: click <strong>📥 to ICL</strong> on any sentence to add its (corrected) annotation to the few-shot pool. Subsequent runs will reuse it. <strong>This is how the loop closes.</strong> Then export, or load more sentences.',
200
- actions: [
201
- { label: '⬇ Export the corpus', handler: 'modal', arg: 'exports' },
202
- ],
203
- };
204
- }
205
- return {
206
- step: 5, icon: '✅', title: 'Loop closed — export or continue',
207
- body: `Your ICL pool now has <strong>${s.icl_pool.size}</strong> entries (version <strong>v${s.icl_pool.version}</strong>). Re-run on more sentences and they will benefit from your corrections. Or export the corpus in TSV / JSON / CoNLL-U / JSONL.`,
208
- actions: [
209
- { label: '⬇ Export', handler: 'modal', arg: 'exports' },
210
- { label: 'Paste more text', handler: 'modal', arg: 'paste' },
211
- ],
212
- };
213
- },
214
-
215
- runGuideAction(a) {
216
- if (a.handler === 'modal') { this.modal = a.arg; return; }
217
- if (a.handler === 'loadExercise') { this.loadExercise(a.arg); return; }
218
- if (a.handler === 'annotateAll') { this.annotateAll(); return; }
219
- if (a.handler === 'openExternal') { window.open(a.arg, '_blank'); return; }
220
- if (a.handler === 'jumpToFirstDisagreement') {
221
- for (let i = 0; i < this.state.sentences.length; i++) {
222
- const ds = this.state.sentences[i].disagreements || [];
223
- if (ds.length > 0) {
224
- const t = ds.sort((x, y) => x.token_idx - y.token_idx)[0].token_idx;
225
- this.openTokenEditor(i, t);
226
- return;
227
- }
228
- }
229
- }
230
- },
231
-
232
- allDisplayableModels() {
233
- const set = new Set(this.state.curated_models);
234
- for (const m of this.state.models) set.add(m);
235
- return Array.from(set);
236
- },
237
-
238
- tokenTooltip(sent, tidx) {
239
- const tok = sent.tokens[tidx];
240
- const lines = [`${tok.surface}`];
241
- for (const f of (this.state.schema?.fields || [])) {
242
- const v = tok[f.name];
243
- if (v && typeof v !== 'object') lines.push(`${f.name}: ${v}`);
244
- }
245
- const dis = (sent.disagreements || []).filter(d => d.token_idx === tidx);
246
- if (dis.length > 0 && Object.keys(sent.per_model || {}).length > 0) {
247
- lines.push('');
248
- lines.push('Per-model votes:');
249
- for (const [m, ann] of Object.entries(sent.per_model)) {
250
- const t = (ann.tokens || [])[tidx] || {};
251
- const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
252
- const tag = enums[0] ? (t[enums[0].name] ?? '∅') : '';
253
- const lemma = t.lemma ? ` ${t.lemma}` : '';
254
- lines.push(` • ${this.modelShort(m)}: ${tag}${lemma}`);
255
- }
256
- } else if (tok._corrected) {
257
- lines.push('(corrected by you)');
258
- }
259
- return lines.join('\n');
260
- },
261
-
262
- async refresh() {
263
- const r = await fetch('/api/state');
264
- const data = await r.json();
265
- this.applyState(data);
266
- },
267
-
268
- rev: 0, // bumped on every state mutation; used as x-for :key suffix to force re-render
269
-
270
- // Mutate state property-by-property and replace nested arrays with fresh references,
271
- // so Alpine reactivity detects every change (replacing `state` wholesale can silently
272
- // miss deep updates in x-for / :class bindings).
273
- applyState(newState) {
274
- if (!newState) return;
275
- for (const k of Object.keys(newState)) {
276
- const v = newState[k];
277
- this.state[k] = Array.isArray(v) ? v.slice() : v;
278
- }
279
- this.rev++;
280
- },
281
-
282
- replaceSentence(sidx, sent) {
283
- const arr = this.state.sentences.slice();
284
- arr[sidx] = sent;
285
- this.state.sentences = arr;
286
- this.rev++;
287
- },
288
-
289
- // ----------- helpers -----------
290
- primaryTag(tok) {
291
- // pick the most informative field for the chip label
292
- if (!this.state.schema) return '';
293
- const enums = this.state.schema.fields.filter(f => f.type === 'enum' && f.name !== 'confidence');
294
- if (enums.length > 0) {
295
- const v = tok[enums[0].name];
296
- if (v) return v;
297
- }
298
- // fallback to lemma if string-typed
299
- if (tok.lemma) return tok.lemma;
300
- return '';
301
- },
302
-
303
- tokenClass(sent, sidx, tidx, tok) {
304
- const isFocus = this.focus.sent === sidx && this.focus.tok === tidx;
305
- const isSelected = this.selection.has(`${sidx}:${tidx}`);
306
- const hasDisagreement = (sent.disagreements || []).some(d => d.token_idx === tidx);
307
- const hasContent = this.primaryTag(tok);
308
- const corrected = !!tok._corrected;
309
- let cls = 'token-base ';
310
- if (hasDisagreement) cls += 'token-warn ';
311
- else if (corrected) cls += 'token-corrected ';
312
- else if (hasContent) cls += 'token-done ';
313
- else cls += 'token-pending ';
314
- if (isFocus) cls += 'token-focus ';
315
- if (isSelected) cls += 'token-selected ';
316
- return cls;
317
- },
318
-
319
- modelShort(m) {
320
- const parts = m.split('/');
321
- return parts[parts.length - 1];
322
- },
323
-
324
- // Per-model accuracy on a single sentence, ONLY shown after the user has
325
- // confirmed the annotation as gold (sent.validated === true). Skips
326
- // confidence/comment (same as disagreement counting).
327
- modelAccuracy(sent) {
328
- if (!sent || sent.status !== 'done' || !sent.validated) return [];
329
- const perModel = sent.per_model || {};
330
- const modelNames = Object.keys(perModel);
331
- if (modelNames.length === 0) return [];
332
- const quiet = new Set(['min', 'priority']);
333
- const fields = (this.state.schema?.fields || []).filter(f => !quiet.has(f.aggregator));
334
- const out = [];
335
- for (const m of modelNames) {
336
- const tokens = perModel[m].tokens || [];
337
- let total = 0, correct = 0;
338
- const n = Math.min(tokens.length, sent.tokens.length);
339
- for (let i = 0; i < n; i++) {
340
- const got = tokens[i] || {};
341
- const ref = sent.tokens[i] || {};
342
- for (const f of fields) {
343
- if (f.type === 'object') {
344
- for (const sub of (f.subfields || [])) {
345
- const a = (got[f.name] || {})[sub.name] ?? null;
346
- const b = (ref[f.name] || {})[sub.name] ?? null;
347
- total++;
348
- if (a === b) correct++;
349
- }
350
- } else {
351
- const a = got[f.name] ?? null;
352
- const b = ref[f.name] ?? null;
353
- total++;
354
- if (a === b) correct++;
355
  }
356
- }
357
- }
358
- out.push({ model: m, pct: total > 0 ? Math.round(100 * correct / total) : 0, correct, total });
359
- }
360
- return out.sort((a, b) => b.pct - a.pct);
361
- },
362
-
363
- accuracyClass(pct) {
364
- if (pct >= 90) return 'accuracy-pill-high';
365
- if (pct >= 70) return 'accuracy-pill-mid';
366
- return 'accuracy-pill-low';
367
- },
368
-
369
- modelTokenSummary(ann, tidx) {
370
- const t = (ann.tokens || [])[tidx] || {};
371
- const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
372
- const lemma = t.lemma ? ` · lemma=${t.lemma}` : '';
373
- const tag = enums[0] ? ` · ${enums[0].name}=${t[enums[0].name] ?? ''}` : '';
374
- const conf = t.confidence ? ` · ${t.confidence}` : '';
375
- return `${tag}${lemma}${conf}`;
376
- },
377
-
378
- currentPresetMatches(key) {
379
- return this.state.schema?.task_name?.toLowerCase().includes(key.replace(/_/g, ' ').replace('tagset', '').trim());
380
- },
381
-
382
- // ----------- mutations: task / settings / models -----------
383
- async setPreset(key) {
384
- const r = await fetch('/api/task/preset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
385
- this.applyState(await r.json());
386
- this.taskEditor.json = JSON.stringify(this.state.schema, null, 2);
387
- this.toast('Task: ' + this.state.schema.task_name, 'ok');
388
- },
389
-
390
- async applyTaskJson() {
391
- try {
392
- const annotation_schema = JSON.parse(this.taskEditor.json);
393
- const r = await fetch('/api/task/schema', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ annotation_schema }) });
394
- if (!r.ok) throw new Error((await r.json()).detail);
395
- this.applyState(await r.json());
396
- this.toast('Custom schema applied.', 'ok');
397
- } catch (e) {
398
- this.toast('Invalid schema JSON: ' + e.message, 'error');
399
- }
400
- },
401
-
402
- async saveSettings(partial) {
403
- const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(partial) });
404
- this.applyState(await r.json());
405
- },
406
-
407
- async setProvider(p) {
408
- if (!this.state.providers.includes(p)) return;
409
- await this.saveSettings({ provider: p });
410
- this.toast(`Provider: ${p}. Models reset to its defaults.`, 'ok');
411
- },
412
-
413
- saveKey() {
414
- const k = (this.keyEditor.value || '').trim();
415
- if (!k) {
416
- if (this.localKey) {
417
- this.toast('No new key entered. Existing key kept.', 'warn');
418
- } else {
419
- this.toast('Paste a key first.', 'warn');
420
- }
421
- return;
422
- }
423
- this.setLocalKey(k);
424
- this.keyEditor.value = '';
425
- this.keyEditor.result = '';
426
- this.keyEditor.ok = false;
427
- this.toast(`✓ ${this.state.provider} key saved in this tab (${k.length} chars). Pill above should turn green.`, 'ok');
428
- this.closeModal();
429
- },
430
-
431
- clearKey() {
432
- this.setLocalKey('');
433
- this.keyEditor.value = '';
434
- this.keyEditor.result = '';
435
- this.keyEditor.ok = false;
436
- this.toast(`${this.state.provider} key cleared from this tab.`, 'ok');
437
- },
438
-
439
- async testKey(autoSaveOnSuccess = false) {
440
- const k = (this.keyEditor.value || this.localKey || '').trim();
441
- if (!k) { this.keyEditor.result = 'Paste a key first.'; this.keyEditor.ok = false; return; }
442
- this.keyEditor.testing = true;
443
- this.keyEditor.result = '';
444
- try {
445
- const r = await fetch('/api/settings/test_key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: k, provider: this.state.provider }) });
446
- const j = await r.json();
447
- this.keyEditor.ok = j.ok;
448
- this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message;
449
- if (j.ok && autoSaveOnSuccess) {
450
- this.setLocalKey(k);
451
- this.keyEditor.value = '';
452
- this.toast(`✓ ${this.state.provider} key tested & saved (${k.length} chars).`, 'ok');
453
- }
454
- } catch (e) {
455
- this.keyEditor.ok = false;
456
- this.keyEditor.result = ' ' + e.message;
457
- }
458
- this.keyEditor.testing = false;
459
- },
460
-
461
- async toggleModel(m) {
462
- const set = new Set(this.state.models);
463
- if (set.has(m)) set.delete(m); else set.add(m);
464
- await this.saveSettings({ models: Array.from(set) });
465
- },
466
-
467
- async addCustomModel() {
468
- const slug = (this.modelEditor.custom || '').trim();
469
- if (!slug) return;
470
- const set = new Set(this.state.models);
471
- set.add(slug);
472
- await this.saveSettings({ models: Array.from(set) });
473
- this.modelEditor.custom = '';
474
- },
475
-
476
- async saveAdvanced() {
477
- await this.saveSettings({
478
- n_icl: this.advEditor.n_icl,
479
- temperature: this.advEditor.temperature,
480
- system_prompt: this.advEditor.system_prompt,
481
- user_template: this.advEditor.user_template,
482
- });
483
- this.toast('Advanced settings saved.', 'ok');
484
- this.closeModal();
485
- },
486
-
487
- // ----------- corpus loading -----------
488
- async loadExercise(idx) {
489
- this.loading = true;
490
- try {
491
- const r = await fetch('/api/corpus/exercise', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idx }) });
492
- this.applyState(await r.json());
493
- this.taskEditor.json = JSON.stringify(this.state.schema, null, 2);
494
- this.toast(`Loaded: ${this.state.exercises[idx].title}`, 'ok');
495
- } finally { this.loading = false; }
496
- },
497
-
498
- onTagKeydown(e) {
499
- if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
500
- e.preventDefault();
501
- this.addCustomTag();
502
- }
503
- },
504
-
505
- addCustomTag() {
506
- const raw = (this.pasteEditor.customTagInput || '').trim();
507
- if (!raw) return;
508
- const parts = raw.split(/[,;\n\t]+/).map(t => t.trim()).filter(Boolean);
509
- for (const t of parts) {
510
- if (!this.pasteEditor.customTags.includes(t)) {
511
- this.pasteEditor.customTags.push(t);
512
- }
513
- }
514
- this.pasteEditor.customTagInput = '';
515
- },
516
-
517
- buildCustomSchema() {
518
- const fields = [];
519
- const baseTags = this.pasteEditor.customTags.slice();
520
- const values = this.pasteEditor.includeNone
521
- ? (baseTags.includes('O') ? baseTags : ['O', ...baseTags])
522
- : baseTags;
523
- fields.push({
524
- name: 'tag',
525
- type: 'enum',
526
- values,
527
- nullable: false,
528
- aggregator: 'vote',
529
- subfields: [],
530
- });
531
- if (this.pasteEditor.includeConfidence) {
532
- fields.push({ name: 'confidence', type: 'enum', values: ['low', 'medium', 'high'], nullable: false, aggregator: 'min', subfields: [] });
533
- }
534
- if (this.pasteEditor.includeComment) {
535
- fields.push({ name: 'comment', type: 'string', values: [], nullable: true, aggregator: 'priority', subfields: [] });
536
- }
537
- return {
538
- task_name: this.pasteEditor.customTaskName || 'Custom task',
539
- language: this.pasteEditor.language || '',
540
- description: '',
541
- fields,
542
- };
543
- },
544
-
545
- async loadPaste() {
546
- // flush any pending tag still in the input
547
- if ((this.pasteEditor.customTagInput || '').trim()) this.addCustomTag();
548
- this.loading = true;
549
- try {
550
- // 1) Set the task BEFORE loading text (so the ICL pool & state are consistent)
551
- if (this.pasteEditor.presetKey === 'custom') {
552
- if (this.pasteEditor.customTags.length === 0) {
553
- this.toast('Add at least one tag in the custom set.', 'warn');
554
- return;
555
- }
556
- const annotation_schema = this.buildCustomSchema();
557
- const r0 = await fetch('/api/task/schema', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ annotation_schema }) });
558
- if (!r0.ok) { this.toast('Schema rejected: ' + (await r0.json()).detail, 'error'); return; }
559
- this.applyState(await r0.json());
560
- } else if (this.pasteEditor.presetKey) {
561
- const r0 = await fetch('/api/task/preset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: this.pasteEditor.presetKey }) });
562
- if (r0.ok) this.applyState(await r0.json());
563
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
565
- // 2) Load the text
566
- const r = await fetch('/api/corpus/paste', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
567
- text: this.pasteEditor.text,
568
- tokenizer: this.pasteEditor.tokenizer,
569
- language: this.pasteEditor.language,
570
- }) });
571
- this.applyState(await r.json());
572
- this.closeModal();
573
- this.toast(`Loaded ${this.state.sentences.length} sentence(s). Task: ${this.state.schema?.task_name}.`, 'ok');
574
- } finally { this.loading = false; }
575
- },
576
-
577
- async clearCorpus() {
578
- const r = await fetch('/api/corpus/clear', { method: 'POST' });
579
- this.applyState(await r.json());
580
- },
581
-
582
- async resetAll() {
583
- if (!confirm('Reset everything? This wipes:\n• loaded corpus\n• annotations\n• ICL pool\n• custom task/schema\n• prompt overrides\n\nYour API key (browser-only) is kept.')) return;
584
- this.loading = true;
585
- try {
586
- const r = await fetch('/api/reset', { method: 'POST' });
587
- this.applyState(await r.json());
588
- this.selection = new Set();
589
- this.focus = { sent: null, tok: null };
590
- this.modal = null;
591
- this.toast('Workspace reset.', 'ok');
592
- } finally { this.loading = false; }
593
- },
594
-
595
- async clearIcl() {
596
- const r = await fetch('/api/icl/clear', { method: 'POST' });
597
- this.applyState(await r.json());
598
- },
599
-
600
- // ----------- annotation -----------
601
- async annotateAll() {
602
- if (!this.canRun || this.loading) return;
603
- this.loading = true;
604
- this.progressText = `Annotating ${this.state.sentences.length} sentences…`;
605
- // optimistic: mark all pending sentences as annotating
606
- this.state.sentences.forEach(s => { if (s.status !== 'done') s.status = 'annotating'; });
607
- try {
608
- const r = await fetch('/api/annotate', { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.keyHeaders() }, body: JSON.stringify({}) });
609
- if (!r.ok) throw new Error((await r.json()).detail);
610
- this.applyState(await r.json());
611
- const dis = this.totalDisagreements;
612
- this.toast(`Done. ${dis} disagreement${dis !== 1 ? 's' : ''} to review.`, dis > 0 ? 'warn' : 'ok');
613
- } catch (e) {
614
- this.toast(e.message, 'error');
615
- } finally { this.loading = false; }
616
- },
617
-
618
- async annotateOne(sidx) {
619
- if (!this.hasKey) { this.modal = 'key'; return; }
620
- this.loading = true;
621
- this.state.sentences[sidx].status = 'annotating';
622
- try {
623
- const r = await fetch('/api/annotate', { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.keyHeaders() }, body: JSON.stringify({ sentence_idxs: [sidx] }) });
624
- if (!r.ok) throw new Error((await r.json()).detail);
625
- this.applyState(await r.json());
626
- const s = this.state.sentences[sidx];
627
- this.toast(`Sentence ${s.id}: ${s.n_disagreements} disagreement(s).`, s.n_disagreements > 0 ? 'warn' : 'ok');
628
- } catch (e) { this.toast(e.message, 'error'); }
629
- this.loading = false;
630
- },
631
-
632
- async addSentenceToIcl(sidx) {
633
- const r = await fetch(`/api/sentence/${sidx}/add_to_icl`, { method: 'POST' });
634
- this.applyState(await r.json());
635
- this.toast(`Added to ICL pool (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`, 'ok');
636
- },
637
-
638
- async setValidated(sidx, value) {
639
- const r = await fetch(`/api/sentence/${sidx}/validate`, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  method: 'POST',
641
- headers: { 'Content-Type': 'application/json' },
642
- body: JSON.stringify({ value }),
643
- });
644
- if (!r.ok) { this.toast('Could not toggle scoring.', 'error'); return; }
645
- const sent = await r.json();
646
- this.replaceSentence(sidx, sent);
647
- this.toast(value ? '📊 Showing per-model accuracy vs your current annotation.' : 'Scores hidden.', 'ok');
648
- },
649
-
650
- // ----------- token editor -----------
651
- onTokenClick(ev, sidx, tidx) {
652
- if (ev.shiftKey) {
653
- this.toggleSelectionIdx(sidx, tidx);
654
  return;
655
- }
656
- this.openTokenEditor(sidx, tidx);
657
- },
658
-
659
- openTokenEditor(sidx, tidx) {
660
- const sent = this.state.sentences[sidx];
661
- const tok = JSON.parse(JSON.stringify(sent.tokens[tidx] || {}));
662
- this.editor.sidx = sidx;
663
- this.editor.tidx = tidx;
664
- this.editor.tok = tok;
665
- this.editor.original = JSON.parse(JSON.stringify(tok)); // snapshot for diff
666
- this.editor.search = {};
667
- this.editor.filtered = {};
668
- this.editor.perModel = sent.per_model || {};
669
- this.editor.disagreementCells = (sent.disagreements || []).filter(d => d.token_idx === tidx);
670
- this.editor.propagateToSimilar = false;
671
- this.focus = { sent: sidx, tok: tidx };
672
- this.modal = 'token';
673
- },
674
-
675
- matchingTokenCount() {
676
- if (!this.editor.tok) return 0;
677
- const surf = this.editor.tok.surface;
678
- let n = 0;
679
- for (let s = 0; s < this.state.sentences.length; s++) {
680
- for (let t = 0; t < this.state.sentences[s].tokens.length; t++) {
681
- if (s === this.editor.sidx && t === this.editor.tidx) continue;
682
- if (this.state.sentences[s].tokens[t].surface === surf) n++;
683
- }
684
- }
685
- return n;
686
- },
687
-
688
- fieldChanges() {
689
- const out = {};
690
- if (!this.editor.tok || !this.editor.original) return out;
691
- for (const k of Object.keys(this.editor.tok)) {
692
- if (k === 'surface' || k.startsWith('_')) continue;
693
- const a = JSON.stringify(this.editor.tok[k] ?? null);
694
- const b = JSON.stringify(this.editor.original[k] ?? null);
695
- if (a !== b) out[k] = this.editor.tok[k];
696
- }
697
- return out;
698
- },
699
-
700
- fieldChangesSummary() {
701
- const c = this.fieldChanges();
702
- return Object.entries(c).map(([k, v]) => {
703
- const val = (v === null || v === undefined) ? '∅' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
704
- return `${k}=${val}`;
705
- }).join(', ');
706
- },
707
-
708
- refreshFilter(name, values) {
709
- const q = (this.editor.search[name] || '').toLowerCase();
710
- this.editor.filtered[name] = values.filter(v => v.toLowerCase().includes(q));
711
- },
712
-
713
- adoptFromModel(model) {
714
- const sent = this.state.sentences[this.editor.sidx];
715
- const t = (sent.per_model[model]?.tokens || [])[this.editor.tidx];
716
- if (!t) return;
717
- // copy all fields except surface
718
- const surface = this.editor.tok.surface;
719
- this.editor.tok = { ...t, surface };
720
- this.toast(`Adopted from ${this.modelShort(model)}.`, 'ok');
721
- },
722
-
723
- async reaskOneToken(model) {
724
- try {
725
- const r = await fetch('/api/annotate/token', { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.keyHeaders() }, body: JSON.stringify({
726
- sent: this.editor.sidx, tok: this.editor.tidx, model,
727
- }) });
728
- if (!r.ok) throw new Error((await r.json()).detail);
729
- const sent = await r.json();
730
- this.replaceSentence(this.editor.sidx, sent);
731
- // re-open with the new token
732
- this.openTokenEditor(this.editor.sidx, this.editor.tidx);
733
- this.toast(`Re-asked ${this.modelShort(model)}.`, 'ok');
734
- } catch (e) { this.toast(e.message, 'error'); }
735
- },
736
-
737
- async reaskOneTokenAt(sidx, tidx, model) {
738
- this.editor.sidx = sidx; this.editor.tidx = tidx;
739
- await this.reaskOneToken(model);
740
- },
741
-
742
- async saveToken() {
743
- const sidx = this.editor.sidx, tidx = this.editor.tidx;
744
- const surface = this.editor.tok.surface;
745
- const changes = this.fieldChanges();
746
- const wantPropagate = this.editor.propagateToSimilar && Object.keys(changes).length > 0 && this.matchingTokenCount() > 0;
747
-
748
- this.editor.tok._corrected = true;
749
- const r = await fetch(`/api/sentence/${sidx}/token/${tidx}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: this.editor.tok }) });
750
- if (!r.ok) { this.toast('Save failed.', 'error'); return; }
751
- const sent = await r.json();
752
- this.replaceSentence(sidx, sent);
753
-
754
- let propagatedCount = 0;
755
- if (wantPropagate) {
756
  try {
757
- const r2 = await fetch('/api/bulk_similar', {
758
- method: 'POST',
759
- headers: { 'Content-Type': 'application/json' },
760
- body: JSON.stringify({
761
- surface,
762
- updates: changes,
763
- exclude: [{ s: sidx, t: tidx }],
764
- }),
765
- });
766
- if (r2.ok) {
767
- const j = await r2.json();
768
- this.applyState(j.state);
769
- propagatedCount = (j.affected || []).length;
770
- }
 
 
 
 
 
771
  } catch (e) {
772
- this.toast('Propagation failed: ' + e.message, 'error');
773
  }
774
- }
 
 
 
 
 
 
 
 
 
 
775
 
776
- // auto-advance
777
- if (this.editor.autoAdvance) {
778
  const next = this.findNextDisagreement(sidx, tidx);
 
779
  if (next) {
780
- this.openTokenEditor(next.s, next.t);
781
- if (propagatedCount > 0) this.toast(`✓ Saved + propagated to ${propagatedCount} other "${surface}".`, 'ok');
782
- return;
783
- }
784
- }
785
- this.closeModal();
786
- this.toast(propagatedCount > 0 ? `✓ Saved + propagated to ${propagatedCount} other "${surface}".` : '✓ Saved.', 'ok');
787
- },
788
-
789
- findNextDisagreement(sidx, tidx) {
790
- const sents = this.state.sentences;
791
- // search rest of current sentence
792
- const sent = sents[sidx];
793
- const more = (sent.disagreements || []).filter(d => d.token_idx > tidx).sort((a, b) => a.token_idx - b.token_idx);
794
- if (more.length > 0) return { s: sidx, t: more[0].token_idx };
795
- // next sentences
796
- for (let i = sidx + 1; i < sents.length; i++) {
797
- const ds = sents[i].disagreements || [];
798
- if (ds.length > 0) {
799
- const t = ds.sort((a, b) => a.token_idx - b.token_idx)[0].token_idx;
800
- return { s: i, t };
801
- }
802
- }
803
- return null;
804
- },
805
-
806
- moveToken(delta) {
807
- const sent = this.state.sentences[this.editor.sidx];
808
- const next = this.editor.tidx + delta;
809
- if (next < 0 || next >= sent.tokens.length) return;
810
- this.openTokenEditor(this.editor.sidx, next);
811
- },
812
-
813
- // ----------- selection / bulk -----------
814
- toggleSelectionIdx(sidx, tidx) {
815
- const k = `${sidx}:${tidx}`;
816
- if (this.selection.has(k)) this.selection.delete(k);
817
- else this.selection.add(k);
818
- // alpine reactivity: replace Set
819
- this.selection = new Set(this.selection);
820
- },
821
-
822
- clearSelection() { this.selection = new Set(); },
823
-
824
- bulkSelectedField() {
825
- return this.schemaFields.find(f => f.name === this.bulkEditor.field);
826
- },
827
-
828
- async applyBulk() {
829
- const bySent = {};
830
- for (const k of this.selection) {
831
- const [s, t] = k.split(':').map(Number);
832
- if (!bySent[s]) bySent[s] = [];
833
- bySent[s].push(t);
834
- }
835
- for (const [s, idxs] of Object.entries(bySent)) {
836
- const r = await fetch(`/api/sentence/${s}/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
837
- token_idxs: idxs, field: this.bulkEditor.field, value: this.bulkEditor.value,
838
- }) });
839
- if (r.ok) this.replaceSentence(Number(s), await r.json());
840
- }
841
- this.clearSelection();
842
- this.closeModal();
843
- this.toast('Bulk applied.', 'ok');
844
- },
845
-
846
- // ----------- context menu -----------
847
- openTokenContext(ev, sidx, tidx) {
848
- this.ctxMenu = { open: true, x: ev.clientX, y: ev.clientY, s: sidx, t: tidx };
849
- },
850
-
851
- // ----------- modals -----------
852
- closeModal() {
853
- this.modal = null;
854
- this.editor.sidx = null; this.editor.tidx = null; this.editor.tok = null;
855
- },
856
-
857
- // ----------- keyboard -----------
858
- globalKey(e) {
859
- // editor-modal: route to editor keys
860
- if (this.modal === 'token' && this.editor.tok) {
861
- if (e.key === 'Escape') { this.closeModal(); e.preventDefault(); return; }
862
- if (e.key === 'Enter' && !(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
863
- this.saveToken(); e.preventDefault(); return;
864
- }
865
- if (e.key === 'ArrowLeft') { this.moveToken(-1); e.preventDefault(); return; }
866
- if (e.key === 'ArrowRight') { this.moveToken(1); e.preventDefault(); return; }
867
- // 1-9 → assign primary enum
868
- const num = parseInt(e.key);
869
- if (!isNaN(num) && num >= 1 && num <= 9) {
870
- const enums = this.schemaFields.filter(f => f.type === 'enum' && f.name !== 'confidence');
871
- if (enums.length > 0) {
872
- const f = enums[0];
873
- const visible = this.editor.filtered[f.name] || f.values;
874
- const v = visible[num - 1];
875
- if (v) { this.editor.tok[f.name] = v; e.preventDefault(); }
876
- }
877
  }
878
- return;
879
- }
880
- if (this.modal) {
881
- if (e.key === 'Escape') { this.closeModal(); e.preventDefault(); }
882
- return;
883
- }
884
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
885
-
886
- // global shortcuts
887
- if (e.key === 'j') { this.moveFocus(1); e.preventDefault(); }
888
- else if (e.key === 'k') { this.moveFocus(-1); e.preventDefault(); }
889
- else if (e.key === 'e' || e.key === 'Enter') {
890
- if (this.focus.sent !== null) { this.openTokenEditor(this.focus.sent, this.focus.tok); e.preventDefault(); }
891
- }
892
- else if (e.key === 'x') {
893
- if (this.focus.sent !== null) { this.toggleSelectionIdx(this.focus.sent, this.focus.tok); e.preventDefault(); }
894
- }
895
- else if (e.key === 'r') {
896
- if (this.focus.sent !== null) { this.annotateOne(this.focus.sent); e.preventDefault(); }
897
- }
898
- else if (e.key === 'Escape') { this.clearSelection(); }
899
- },
900
-
901
- handleKey(e) { /* main panel passthrough — globalKey handles all */ },
902
-
903
- moveFocus(delta) {
904
- const sents = this.state.sentences;
905
- if (sents.length === 0) return;
906
- if (this.focus.sent === null) {
907
- this.focus = { sent: 0, tok: 0 };
908
- return;
909
- }
910
- let s = this.focus.sent, t = this.focus.tok + delta;
911
- while (s >= 0 && s < sents.length) {
912
- if (t < 0) { s -= 1; if (s < 0) return; t = sents[s].tokens.length - 1; continue; }
913
- if (t >= sents[s].tokens.length) { s += 1; t = 0; continue; }
914
- this.focus = { sent: s, tok: t };
915
- // scroll into view
916
- this.$nextTick(() => {
917
- const el = document.querySelector(`button.token-base[data-sent="${s}"][data-tok="${t}"]`);
918
- if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
919
- });
920
- return;
921
- }
922
- },
923
-
924
- // ----------- toasts -----------
925
- toast(msg, kind = 'ok') {
926
- const id = this.nextToastId++;
927
- this.toasts.push({ id, msg, kind });
928
- setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, 3500);
929
- },
930
-
931
- // ----------- markdown -> html (minimal) -----------
932
- markdownToHtml(md) {
933
- // very lightweight; safe enough for trusted local content
934
- let h = md
935
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
936
- .replace(/^### (.*)$/gm, '<h3>$1</h3>')
937
- .replace(/^## (.*)$/gm, '<h2>$1</h2>')
938
- .replace(/^# (.*)$/gm, '<h1>$1</h1>')
939
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
940
- .replace(/`([^`]+)`/g, '<code>$1</code>')
941
- .replace(/^- (.*)$/gm, '<li>$1</li>')
942
- .replace(/^\d+\. (.*)$/gm, '<li>$1</li>');
943
- h = h.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
944
- h = h.split(/\n{2,}/).map(p => /^<[hul]/.test(p) ? p : '<p>' + p.replace(/\n/g, '<br>') + '</p>').join('\n');
945
- return h;
946
- },
947
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
948
  }
 
1
  // LREC 2026 — LLM Annotator front-end logic (Alpine.js)
2
 
3
  function annotator() {
4
+ return {
5
+ // ----------- state -----------
6
+ paperLink: 'https://aclanthology.org/2026.loreslm-1.28/',
7
+ loading: false,
8
+ progressText: 'Annotating…',
9
+ modal: null,
10
+ cheatsheetHtml: '',
11
+ toasts: [],
12
+ nextToastId: 1,
13
+ focus: {sent: null, tok: null},
14
+ selection: new Set(),
15
+ ctxMenu: {open: false, x: 0, y: 0, s: null, t: null},
16
+ guideDismissed: false,
17
+ moeBannerDismissed: false,
18
+ moeHintDismissed: false,
19
+ // Per-provider client-side keys; persisted in sessionStorage only
20
+ localKeys: {openrouter: '', mistral: '', openai: '', ilaas: ''},
21
+
22
+ state: {
23
+ schema: null,
24
+ schema_hash: '',
25
+ json_schema: {},
26
+ language: '',
27
+ system_prompt: '',
28
+ user_template: '',
29
+ has_env_key: false,
30
+ provider: 'openrouter',
31
+ providers: ['openrouter', 'mistral', 'openai', 'ilaas'],
32
+ curated_models_by_provider: {},
33
+ models: [],
34
+ priority: [],
35
+ temperature: 0,
36
+ n_icl: 5,
37
+ icl_pool: {version: 0, size: 0, entries: []},
38
+ sentences: [],
39
+ presets: [],
40
+ curated_models: [],
41
+ aggregators: [],
42
+ exercises: [],
43
+ },
44
+
45
+ editor: {
46
+ sidx: null, tidx: null,
47
+ tok: null,
48
+ original: null, // snapshot at modal-open, used to diff field changes
49
+ perModel: {},
50
+ disagreementCells: [],
51
+ search: {},
52
+ filtered: {},
53
+ autoAdvance: true,
54
+ propagateToSimilar: false,
55
+ },
56
+
57
+ taskEditor: {json: ''},
58
+ modelEditor: {custom: '', priority: ''},
59
+ keyEditor: {value: '', testing: false, result: '', ok: false},
60
+ pasteEditor: {
61
+ text: '',
62
+ tokenizer: 'whitespace',
63
+ language: '',
64
+ presetKey: 'ud_upos_morph',
65
+ customTaskName: 'My custom task',
66
+ customTagInput: '',
67
+ customTags: [],
68
+ includeNone: true,
69
+ includeConfidence: true,
70
+ includeComment: false,
71
+ },
72
+ advEditor: {system_prompt: '', user_template: '', n_icl: 5, temperature: 0},
73
+ bulkEditor: {field: '', value: ''},
74
+
75
+ // ----------- derived -----------
76
+ get schema() {
77
+ return this.state.schema;
78
+ },
79
+ get schemaFields() {
80
+ const f = (this.state.schema && this.state.schema.fields) || [];
81
+ return f;
82
+ },
83
+ get totalTokens() {
84
+ return this.state.sentences.reduce((a, s) => a + s.tokens.length, 0);
85
+ },
86
+ get totalDisagreements() {
87
+ return this.state.sentences.reduce((a, s) => a + (s.n_disagreements || 0), 0);
88
+ },
89
+ // ----------- key helpers (per-provider) -----------
90
+ get localKey() {
91
+ return this.localKeys[this.state.provider] || '';
92
+ },
93
+ setLocalKey(value) {
94
+ const p = this.state.provider;
95
+ this.localKeys = {...this.localKeys, [p]: value};
96
+ try {
97
+ sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys));
98
+ } catch (e) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  }
100
+ },
101
+ get hasKey() {
102
+ return !!this.localKey || !!this.state.has_env_key;
103
+ },
104
+ get canRun() {
105
+ return this.hasKey && this.state.models.length > 0 && this.state.sentences.length > 0;
106
+ },
107
+ keyHeaders() {
108
+ const h = {'X-LLM-Provider': this.state.provider};
109
+ if (this.localKey) h['X-API-Key'] = this.localKey;
110
+ return h;
111
+ },
112
+
113
+ // ----------- init -----------
114
+ async init() {
115
+ this.guideDismissed = localStorage.getItem('guideDismissed') === '1';
116
+ this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1';
117
+ this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1';
118
+ // Load per-provider keys; migrate legacy single-key key if present
119
+ try {
120
+ const raw = sessionStorage.getItem('llm_keys');
121
+ if (raw) {
122
+ this.localKeys = {
123
+ openrouter: '',
124
+ mistral: '',
125
+ openai: '',
126
+ ilaas: '',
127
+ ...JSON.parse(raw),
128
+ };
129
+ }
130
+ const legacy = sessionStorage.getItem('openrouter_key');
131
+ if (legacy && !this.localKeys.openrouter) {
132
+ this.localKeys = {...this.localKeys, openrouter: legacy};
133
+ sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys));
134
+ sessionStorage.removeItem('openrouter_key');
135
+ }
136
+ } catch (e) {
137
+ }
138
+ await this.refresh();
139
+ try {
140
+ const r = await fetch('/api/cheatsheet');
141
+ const txt = await r.text();
142
+ this.cheatsheetHtml = this.markdownToHtml(txt);
143
+ } catch (e) {
144
+ }
145
+ // sync editor mirrors
146
+ this.taskEditor.json = JSON.stringify(this.state.schema, null, 2);
147
+ this.modelEditor.priority = this.state.priority.join(', ');
148
+ this.advEditor.system_prompt = this.state.system_prompt;
149
+ this.advEditor.user_template = this.state.user_template;
150
+ this.advEditor.n_icl = this.state.n_icl;
151
+ this.advEditor.temperature = this.state.temperature;
152
+ window.addEventListener('keydown', (e) => this.globalKey(e));
153
+ // persist dismissals
154
+ this.$watch?.('moeBannerDismissed', v => localStorage.setItem('moeBannerDismissed', v ? '1' : '0'));
155
+ this.$watch?.('moeHintDismissed', v => localStorage.setItem('moeHintDismissed', v ? '1' : '0'));
156
+ },
157
+
158
+ // ----------- contextual guide -----------
159
+ get guide() {
160
+ const s = this.state;
161
+ if (s.sentences.length === 0) {
162
+ return {
163
+ step: 1, icon: '📜', title: 'Load a corpus to start',
164
+ body: 'Pick a sandbox example in the left sidebar — Greek, Armenian or Syriac. They come with a task preset, a validated tagset, and 3–5 pre-loaded ICL examples (visible in the toolbar: <strong>ICL pool · v3 · 5 ex</strong>).',
165
+ actions: [
166
+ {label: '📘 Try Armenian (HYE)', handler: 'loadExercise', arg: 1},
167
+ {label: 'Paste my own text', handler: 'modal', arg: 'paste'},
168
+ ],
169
+ };
170
+ }
171
+ if (!this.hasKey) {
172
+ const providerLabel = s.provider || 'provider';
173
+ const body = s.provider === 'openrouter'
174
+ ? 'One OpenRouter key gives you access to Claude, GPT, Mistral, Llama, Qwen, DeepSeek and more. The key is kept <strong>in this browser tab only</strong>...'
175
+ : `Add your <strong>${providerLabel}</strong> API key. The key is kept <strong>in this browser tab only</strong> and sent as an <code>X-API-Key</code> header.`;
176
+ return {
177
+ step: 2,
178
+ icon: '🔑',
179
+ title: `Add your ${providerLabel} API key`,
180
+ body,
181
+ actions: [
182
+ {label: 'Add API key', handler: 'modal', arg: 'key'},
183
+ ],
184
+ };
185
+ }
186
+ const anyDone = s.sentences.some(x => x.status === 'done');
187
+ const anyPending = s.sentences.some(x => x.status === 'pending');
188
+ const totalDis = this.totalDisagreements;
189
+ const lastWasMoE = s.sentences.some(x => Object.keys(x.per_model || {}).length >= 2);
190
+
191
+ if (!anyDone) {
192
+ const moeNote = s.models.length >= 2
193
+ ? `<strong>MoE is ON</strong> — your ${s.models.length} models will be called in parallel and their answers voted token-by-token.`
194
+ : `Single model mode. To enable <strong>Mixture-of-Experts</strong> (parallel models + per-token vote), add a 2nd model.`;
195
+ return {
196
+ step: 3, icon: '▶️', title: 'Run the first annotation',
197
+ body: `Click <strong>Annotate all</strong> in the toolbar. The ${s.icl_pool.size} ICL examples already in the pool will be sent as few-shot context. ${moeNote}`,
198
+ actions: [
199
+ {label: '▶ Annotate all', handler: 'annotateAll'},
200
+ {label: 'Add a 2nd model (MoE)', handler: 'modal', arg: 'models', show: s.models.length < 2},
201
+ ].filter(a => a.show !== false),
202
+ };
203
+ }
204
+ if (totalDis > 0) {
205
+ const moeMsg = lastWasMoE
206
+ ? `Each amber token has at least one field where your models disagreed. Click it to see <em>which model said what</em> and pick the right answer (or click <kbd>adopt</kbd> next to one model).`
207
+ : `Click a token to edit it: change its tag, lemma, or any field. With keyboard: <kbd>e</kbd> to edit, <kbd>↵</kbd> to save & auto-advance to the next ⚠.`;
208
+ return {
209
+ step: 4, icon: '⚠', title: `Review ${totalDis} disagreement${totalDis !== 1 ? 's' : ''}`,
210
+ body: moeMsg,
211
+ actions: [
212
+ {label: 'Open first ⚠', handler: 'jumpToFirstDisagreement'},
213
+ ],
214
+ };
215
+ }
216
+ if (s.icl_pool.entries.filter(e => e.source === 'corrected').length === 0 && anyDone) {
217
+ return {
218
+ step: 5, icon: '📥', title: 'Feed corrections back to ICL',
219
+ body: 'Your sentences look consensual. To bootstrap: click <strong>📥 to ICL</strong> on any sentence to add its (corrected) annotation to the few-shot pool. Subsequent runs will reuse it. <strong>This is how the loop closes.</strong> Then export, or load more sentences.',
220
+ actions: [
221
+ {label: '⬇ Export the corpus', handler: 'modal', arg: 'exports'},
222
+ ],
223
+ };
224
+ }
225
+ return {
226
+ step: 5, icon: '✅', title: 'Loop closed — export or continue',
227
+ body: `Your ICL pool now has <strong>${s.icl_pool.size}</strong> entries (version <strong>v${s.icl_pool.version}</strong>). Re-run on more sentences and they will benefit from your corrections. Or export the corpus in TSV / JSON / CoNLL-U / JSONL.`,
228
+ actions: [
229
+ {label: '⬇ Export', handler: 'modal', arg: 'exports'},
230
+ {label: 'Paste more text', handler: 'modal', arg: 'paste'},
231
+ ],
232
+ };
233
+ },
234
+
235
+ runGuideAction(a) {
236
+ if (a.handler === 'modal') {
237
+ this.modal = a.arg;
238
+ return;
239
+ }
240
+ if (a.handler === 'loadExercise') {
241
+ this.loadExercise(a.arg);
242
+ return;
243
+ }
244
+ if (a.handler === 'annotateAll') {
245
+ this.annotateAll();
246
+ return;
247
+ }
248
+ if (a.handler === 'openExternal') {
249
+ window.open(a.arg, '_blank');
250
+ return;
251
+ }
252
+ if (a.handler === 'jumpToFirstDisagreement') {
253
+ for (let i = 0; i < this.state.sentences.length; i++) {
254
+ const ds = this.state.sentences[i].disagreements || [];
255
+ if (ds.length > 0) {
256
+ const t = ds.sort((x, y) => x.token_idx - y.token_idx)[0].token_idx;
257
+ this.openTokenEditor(i, t);
258
+ return;
259
+ }
260
+ }
261
+ }
262
+ },
263
+
264
+ allDisplayableModels() {
265
+ const set = new Set(this.state.curated_models);
266
+ for (const m of this.state.models) set.add(m);
267
+ return Array.from(set);
268
+ },
269
+
270
+ tokenTooltip(sent, tidx) {
271
+ const tok = sent.tokens[tidx];
272
+ const lines = [`${tok.surface}`];
273
+ for (const f of (this.state.schema?.fields || [])) {
274
+ const v = tok[f.name];
275
+ if (v && typeof v !== 'object') lines.push(`${f.name}: ${v}`);
276
+ }
277
+ const dis = (sent.disagreements || []).filter(d => d.token_idx === tidx);
278
+ if (dis.length > 0 && Object.keys(sent.per_model || {}).length > 0) {
279
+ lines.push('');
280
+ lines.push('Per-model votes:');
281
+ for (const [m, ann] of Object.entries(sent.per_model)) {
282
+ const t = (ann.tokens || [])[tidx] || {};
283
+ const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
284
+ const tag = enums[0] ? (t[enums[0].name] ?? '∅') : '';
285
+ const lemma = t.lemma ? ` ${t.lemma}` : '';
286
+ lines.push(` • ${this.modelShort(m)}: ${tag}${lemma}`);
287
+ }
288
+ } else if (tok._corrected) {
289
+ lines.push('(corrected by you)');
290
+ }
291
+ return lines.join('\n');
292
+ },
293
+
294
+ async refresh() {
295
+ const r = await fetch('/api/state');
296
+ const data = await r.json();
297
+ this.applyState(data);
298
+ },
299
+ rev: 0, // bumped on every state mutation; used as x-for :key suffix to force re-render
300
+ // Mutate state property-by-property and replace nested arrays with fresh references,
301
+ // so Alpine reactivity detects every change (replacing `state` wholesale can silently
302
+ // miss deep updates in x-for / :class bindings).
303
+ applyState(newState) {
304
+ if (!newState) return;
305
+ for (const k of Object.keys(newState)) {
306
+ const v = newState[k];
307
+ if (k === 'sentences') {
308
+ this.state.sentences = (v || []).map(s => ({
309
+ ...s,
310
+ tokens: (s.tokens || []).map(t => ({...t})),
311
+ disagreements: [...(s.disagreements || [])],
312
+ per_model: {...(s.per_model || {})},
313
+ }));
314
+ } else if (Array.isArray(v)) {
315
+ this.state[k] = [...v];
316
+ } else if (v && typeof v === 'object') {
317
+ this.state[k] = {...v};
318
+ } else {
319
+ this.state[k] = v;
320
+ }
321
+ }
322
+ for (const sent of this.state.sentences || []) {
323
+ if (sent.validated) {
324
+ sent._accuracy = this.modelAccuracy(sent);
325
+ }
326
+ }
327
+ this.rev++;
328
+ },
329
+ replaceSentence(sidx, sent) {
330
+ const arr = [...this.state.sentences];
331
+ arr[sidx] = {
332
+ ...sent,
333
+ tokens: (sent.tokens || []).map(t => ({...t})),
334
+ disagreements: [...(sent.disagreements || [])],
335
+ per_model: {...(sent.per_model || {})},
336
+ };
337
+ this.state.sentences = arr;
338
+ this.rev++;
339
+ },
340
+
341
+ // ----------- helpers -----------
342
+ primaryTag(tok) {
343
+ // pick the most informative field for the chip label
344
+ if (!this.state.schema) return '';
345
+ const enums = this.state.schema.fields.filter(f => f.type === 'enum' && f.name !== 'confidence');
346
+ if (enums.length > 0) {
347
+ const v = tok[enums[0].name];
348
+ if (v) return v;
349
+ }
350
+ // fallback to lemma if string-typed
351
+ if (tok.lemma) return tok.lemma;
352
+ return '';
353
+ },
354
+
355
+ tokenClass(sent, sidx, tidx, tok) {
356
+ const isFocus = this.focus.sent === sidx && this.focus.tok === tidx;
357
+ const isSelected = this.selection.has(`${sidx}:${tidx}`);
358
+ const hasDisagreement = (sent.disagreements || []).some(d => d.token_idx === tidx);
359
+ const hasContent = this.primaryTag(tok);
360
+ const corrected = !!tok._corrected;
361
+ let cls = 'token-base ';
362
+ if (hasDisagreement) cls += 'token-warn ';
363
+ else if (corrected) cls += 'token-corrected ';
364
+ else if (hasContent) cls += 'token-done ';
365
+ else cls += 'token-pending ';
366
+ if (isFocus) cls += 'token-focus ';
367
+ if (isSelected) cls += 'token-selected ';
368
+ return cls;
369
+ },
370
+
371
+ modelShort(m) {
372
+ const parts = m.split('/');
373
+ return parts[parts.length - 1];
374
+ },
375
+
376
+ // Per-model accuracy on a single sentence, ONLY shown after the user has
377
+ // confirmed the annotation as gold (sent.validated === true). Skips
378
+ // confidence/comment (same as disagreement counting).
379
+ modelAccuracy(sent) {
380
+ if (!sent || sent.status !== 'done' || !sent.validated) return [];
381
+ const perModel = sent.per_model || {};
382
+ const modelNames = Object.keys(perModel);
383
+ if (modelNames.length === 0) return [];
384
+ const quiet = new Set(['min', 'priority']);
385
+ const fields = (this.state.schema?.fields || []).filter(f => !quiet.has(f.aggregator));
386
+ const out = [];
387
+ for (const m of modelNames) {
388
+ const tokens = perModel[m].tokens || [];
389
+ let total = 0, correct = 0;
390
+ const n = Math.min(tokens.length, sent.tokens.length);
391
+ for (let i = 0; i < n; i++) {
392
+ const got = tokens[i] || {};
393
+ const ref = sent.tokens[i] || {};
394
+ for (const f of fields) {
395
+ if (f.type === 'object') {
396
+ for (const sub of (f.subfields || [])) {
397
+ const a = (got[f.name] || {})[sub.name] ?? null;
398
+ const b = (ref[f.name] || {})[sub.name] ?? null;
399
+ total++;
400
+ if (a === b) correct++;
401
+ }
402
+ } else {
403
+ const a = got[f.name] ?? null;
404
+ const b = ref[f.name] ?? null;
405
+ total++;
406
+ if (a === b) correct++;
407
+ }
408
+ }
409
+ }
410
+ out.push({model: m, pct: total > 0 ? Math.round(100 * correct / total) : 0, correct, total});
411
+ }
412
+ return out.sort((a, b) => b.pct - a.pct);
413
+ },
414
+
415
+ accuracyClass(pct) {
416
+ if (pct >= 90) return 'accuracy-pill-high';
417
+ if (pct >= 70) return 'accuracy-pill-mid';
418
+ return 'accuracy-pill-low';
419
+ },
420
+
421
+ modelTokenSummary(ann, tidx) {
422
+ const t = (ann.tokens || [])[tidx] || {};
423
+ const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
424
+ const lemma = t.lemma ? ` · lemma=${t.lemma}` : '';
425
+ const tag = enums[0] ? ` · ${enums[0].name}=${t[enums[0].name] ?? '∅'}` : '';
426
+ const conf = t.confidence ? ` · ${t.confidence}` : '';
427
+ return `${tag}${lemma}${conf}`;
428
+ },
429
+
430
+ currentPresetMatches(key) {
431
+ return this.state.schema?.task_name?.toLowerCase().includes(key.replace(/_/g, ' ').replace('tagset', '').trim());
432
+ },
433
+
434
+ // ----------- mutations: task / settings / models -----------
435
+ async setPreset(key) {
436
+ const r = await fetch('/api/task/preset', {
437
+ method: 'POST',
438
+ headers: {'Content-Type': 'application/json'},
439
+ body: JSON.stringify({key})
440
+ });
441
+ this.applyState(await r.json());
442
+ this.taskEditor.json = JSON.stringify(this.state.schema, null, 2);
443
+ this.toast('Task: ' + this.state.schema.task_name, 'ok');
444
+ },
445
+
446
+ async applyTaskJson() {
447
+ try {
448
+ const annotation_schema = JSON.parse(this.taskEditor.json);
449
+ const r = await fetch('/api/task/schema', {
450
+ method: 'POST',
451
+ headers: {'Content-Type': 'application/json'},
452
+ body: JSON.stringify({annotation_schema})
453
+ });
454
+ if (!r.ok) throw new Error((await r.json()).detail);
455
+ this.applyState(await r.json());
456
+ this.toast('Custom schema applied.', 'ok');
457
+ } catch (e) {
458
+ this.toast('Invalid schema JSON: ' + e.message, 'error');
459
+ }
460
+ },
461
+
462
+ async saveSettings(partial) {
463
+ const r = await fetch('/api/settings', {
464
+ method: 'POST',
465
+ headers: {'Content-Type': 'application/json'},
466
+ body: JSON.stringify(partial)
467
+ });
468
+ this.applyState(await r.json());
469
+ },
470
+
471
+ async setProvider(p) {
472
+ if (!this.state.providers.includes(p)) return;
473
+ await this.saveSettings({provider: p});
474
+ this.toast(`Provider: ${p}. Models reset to its defaults.`, 'ok');
475
+ },
476
+
477
+ saveKey() {
478
+ const k = (this.keyEditor.value || '').trim();
479
+ if (!k) {
480
+ if (this.localKey) {
481
+ this.toast('No new key entered. Existing key kept.', 'warn');
482
+ } else {
483
+ this.toast('Paste a key first.', 'warn');
484
+ }
485
+ return;
486
+ }
487
+ this.setLocalKey(k);
488
+ this.keyEditor.value = '';
489
+ this.keyEditor.result = '';
490
+ this.keyEditor.ok = false;
491
+ this.toast(`✓ ${this.state.provider} key saved in this tab (${k.length} chars). Pill above should turn green.`, 'ok');
492
+ this.closeModal();
493
+ },
494
+
495
+ clearKey() {
496
+ this.setLocalKey('');
497
+ this.keyEditor.value = '';
498
+ this.keyEditor.result = '';
499
+ this.keyEditor.ok = false;
500
+ this.toast(`${this.state.provider} key cleared from this tab.`, 'ok');
501
+ },
502
+
503
+ async testKey(autoSaveOnSuccess = false) {
504
+ const k = (this.keyEditor.value || this.localKey || '').trim();
505
+ if (!k) {
506
+ this.keyEditor.result = 'Paste a key first.';
507
+ this.keyEditor.ok = false;
508
+ return;
509
+ }
510
+ this.keyEditor.testing = true;
511
+ this.keyEditor.result = '';
512
+ try {
513
+ const r = await fetch('/api/settings/test_key', {
514
+ method: 'POST',
515
+ headers: {'Content-Type': 'application/json'},
516
+ body: JSON.stringify({api_key: k, provider: this.state.provider})
517
+ });
518
+ const j = await r.json();
519
+ this.keyEditor.ok = j.ok;
520
+ this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message;
521
+ if (j.ok && autoSaveOnSuccess) {
522
+ this.setLocalKey(k);
523
+ this.keyEditor.value = '';
524
+ this.toast(`✓ ${this.state.provider} key tested & saved (${k.length} chars).`, 'ok');
525
+ }
526
+ } catch (e) {
527
+ this.keyEditor.ok = false;
528
+ this.keyEditor.result = '✗ ' + e.message;
529
+ }
530
+ this.keyEditor.testing = false;
531
+ },
532
+
533
+ async toggleModel(m) {
534
+ const set = new Set(this.state.models);
535
+ if (set.has(m)) set.delete(m); else set.add(m);
536
+ await this.saveSettings({models: Array.from(set)});
537
+ },
538
+
539
+ async addCustomModel() {
540
+ const slug = (this.modelEditor.custom || '').trim();
541
+ if (!slug) return;
542
+ const set = new Set(this.state.models);
543
+ set.add(slug);
544
+ await this.saveSettings({models: Array.from(set)});
545
+ this.modelEditor.custom = '';
546
+ },
547
+
548
+ async saveAdvanced() {
549
+ await this.saveSettings({
550
+ n_icl: this.advEditor.n_icl,
551
+ temperature: this.advEditor.temperature,
552
+ system_prompt: this.advEditor.system_prompt,
553
+ user_template: this.advEditor.user_template,
554
+ });
555
+ this.toast('Advanced settings saved.', 'ok');
556
+ this.closeModal();
557
+ },
558
+
559
+ // ----------- corpus loading -----------
560
+ async loadExercise(idx) {
561
+ this.loading = true;
562
+ try {
563
+ const r = await fetch('/api/corpus/exercise', {
564
+ method: 'POST',
565
+ headers: {'Content-Type': 'application/json'},
566
+ body: JSON.stringify({idx})
567
+ });
568
+ this.applyState(await r.json());
569
+ this.taskEditor.json = JSON.stringify(this.state.schema, null, 2);
570
+ this.toast(`Loaded: ${this.state.exercises[idx].title}`, 'ok');
571
+ } finally {
572
+ this.loading = false;
573
+ }
574
+ },
575
+
576
+ onTagKeydown(e) {
577
+ if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
578
+ e.preventDefault();
579
+ this.addCustomTag();
580
+ }
581
+ },
582
+
583
+ addCustomTag() {
584
+ const raw = (this.pasteEditor.customTagInput || '').trim();
585
+ if (!raw) return;
586
+ const parts = raw.split(/[,;\n\t]+/).map(t => t.trim()).filter(Boolean);
587
+ for (const t of parts) {
588
+ if (!this.pasteEditor.customTags.includes(t)) {
589
+ this.pasteEditor.customTags.push(t);
590
+ }
591
+ }
592
+ this.pasteEditor.customTagInput = '';
593
+ },
594
+
595
+ buildCustomSchema() {
596
+ const fields = [];
597
+ const baseTags = this.pasteEditor.customTags.slice();
598
+ const values = this.pasteEditor.includeNone
599
+ ? (baseTags.includes('O') ? baseTags : ['O', ...baseTags])
600
+ : baseTags;
601
+ fields.push({
602
+ name: 'tag',
603
+ type: 'enum',
604
+ values,
605
+ nullable: false,
606
+ aggregator: 'vote',
607
+ subfields: [],
608
+ });
609
+ if (this.pasteEditor.includeConfidence) {
610
+ fields.push({
611
+ name: 'confidence',
612
+ type: 'enum',
613
+ values: ['low', 'medium', 'high'],
614
+ nullable: false,
615
+ aggregator: 'min',
616
+ subfields: []
617
+ });
618
+ }
619
+ if (this.pasteEditor.includeComment) {
620
+ fields.push({
621
+ name: 'comment',
622
+ type: 'string',
623
+ values: [],
624
+ nullable: true,
625
+ aggregator: 'priority',
626
+ subfields: []
627
+ });
628
+ }
629
+ return {
630
+ task_name: this.pasteEditor.customTaskName || 'Custom task',
631
+ language: this.pasteEditor.language || '',
632
+ description: '',
633
+ fields,
634
+ };
635
+ },
636
+
637
+ async loadPaste() {
638
+ // flush any pending tag still in the input
639
+ if ((this.pasteEditor.customTagInput || '').trim()) this.addCustomTag();
640
+ this.loading = true;
641
+ try {
642
+ // 1) Set the task BEFORE loading text (so the ICL pool & state are consistent)
643
+ if (this.pasteEditor.presetKey === 'custom') {
644
+ if (this.pasteEditor.customTags.length === 0) {
645
+ this.toast('Add at least one tag in the custom set.', 'warn');
646
+ return;
647
+ }
648
+ const annotation_schema = this.buildCustomSchema();
649
+ const r0 = await fetch('/api/task/schema', {
650
+ method: 'POST',
651
+ headers: {'Content-Type': 'application/json'},
652
+ body: JSON.stringify({annotation_schema})
653
+ });
654
+ if (!r0.ok) {
655
+ this.toast('Schema rejected: ' + (await r0.json()).detail, 'error');
656
+ return;
657
+ }
658
+ this.applyState(await r0.json());
659
+ } else if (this.pasteEditor.presetKey) {
660
+ const r0 = await fetch('/api/task/preset', {
661
+ method: 'POST',
662
+ headers: {'Content-Type': 'application/json'},
663
+ body: JSON.stringify({key: this.pasteEditor.presetKey})
664
+ });
665
+ if (r0.ok) this.applyState(await r0.json());
666
+ }
667
+
668
+ // 2) Load the text
669
+ const r = await fetch('/api/corpus/paste', {
670
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({
671
+ text: this.pasteEditor.text,
672
+ tokenizer: this.pasteEditor.tokenizer,
673
+ language: this.pasteEditor.language,
674
+ })
675
+ });
676
+ this.applyState(await r.json());
677
+ this.closeModal();
678
+ this.toast(`Loaded ${this.state.sentences.length} sentence(s). Task: ${this.state.schema?.task_name}.`, 'ok');
679
+ } finally {
680
+ this.loading = false;
681
+ }
682
+ },
683
+
684
+ async clearCorpus() {
685
+ const r = await fetch('/api/corpus/clear', {method: 'POST'});
686
+ this.applyState(await r.json());
687
+ },
688
+
689
+ async resetAll() {
690
+ if (!confirm('Reset everything? This wipes:\n• loaded corpus\n• annotations\n• ICL pool\n• custom task/schema\n• prompt overrides\n\nYour API key (browser-only) is kept.')) return;
691
+ this.loading = true;
692
+ try {
693
+ const r = await fetch('/api/reset', {method: 'POST'});
694
+ this.applyState(await r.json());
695
+ this.selection = new Set();
696
+ this.focus = {sent: null, tok: null};
697
+ this.modal = null;
698
+ this.toast('Workspace reset.', 'ok');
699
+ } finally {
700
+ this.loading = false;
701
+ }
702
+ },
703
+
704
+ async clearIcl() {
705
+ const r = await fetch('/api/icl/clear', {method: 'POST'});
706
+ this.applyState(await r.json());
707
+ },
708
+
709
+ // ----------- annotation -----------
710
+ async annotateAll() {
711
+ if (!this.canRun || this.loading) return;
712
+
713
+ this.loading = true;
714
+ this.progressText = `Annotating ${this.state.sentences.length} sentences…`;
715
+
716
+ // Optimistic UI: nouvelle référence pour Alpine
717
+ this.state.sentences = this.state.sentences.map(s =>
718
+ s.status !== 'done' ? {...s, status: 'annotating'} : s
719
+ );
720
+
721
+ try {
722
+ const r = await fetch('/api/annotate', {
723
+ method: 'POST',
724
+ headers: {'Content-Type': 'application/json', ...this.keyHeaders()},
725
+ body: JSON.stringify({})
726
+ });
727
+
728
+ if (!r.ok) throw new Error((await r.json()).detail);
729
+
730
+ const data = await r.json();
731
+ this.applyState(data);
732
+
733
+ const dis = this.totalDisagreements;
734
+ this.toast(
735
+ `Done. ${dis} disagreement${dis !== 1 ? 's' : ''} to review.`,
736
+ dis > 0 ? 'warn' : 'ok'
737
+ );
738
+ } catch (e) {
739
+ this.toast(e.message, 'error');
740
+ } finally {
741
+ this.loading = false;
742
+ }
743
+ },
744
 
745
+ async annotateOne(sidx) {
746
+ if (!this.hasKey) {
747
+ this.modal = 'key';
748
+ return;
749
+ }
750
+ this.loading = true;
751
+ this.state.sentences = this.state.sentences.map((s, i) =>
752
+ i === sidx ? {...s, status: 'annotating'} : s
753
+ );
754
+ try {
755
+ const r = await fetch('/api/annotate', {
756
+ method: 'POST',
757
+ headers: {'Content-Type': 'application/json', ...this.keyHeaders()},
758
+ body: JSON.stringify({sentence_idxs: [sidx]})
759
+ });
760
+ if (!r.ok) throw new Error((await r.json()).detail);
761
+ this.applyState(await r.json());
762
+ const s = this.state.sentences[sidx];
763
+ this.toast(`Sentence ${s.id}: ${s.n_disagreements} disagreement(s).`, s.n_disagreements > 0 ? 'warn' : 'ok');
764
+ } catch (e) {
765
+ this.toast(e.message, 'error');
766
+ }
767
+ this.loading = false;
768
+ },
769
+
770
+ async addSentenceToIcl(sidx) {
771
+ const r = await fetch(`/api/sentence/${sidx}/add_to_icl`, {method: 'POST'});
772
+
773
+ if (!r.ok) {
774
+ this.toast('Could not add to ICL pool.', 'error');
775
+ return;
776
+ }
777
+
778
+ const data = await r.json();
779
+ this.applyState(data);
780
+
781
+ if (data.icl_add_result === 'unchanged') {
782
+ this.toast(
783
+ `Already in ICL pool — unchanged (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`,
784
+ 'warn'
785
+ );
786
+ } else if (data.icl_add_result === 'updated') {
787
+ this.toast(
788
+ `Updated existing ICL example after correction (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`,
789
+ 'ok'
790
+ );
791
+ } else {
792
+ this.toast(
793
+ `Added to ICL pool (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`,
794
+ 'ok'
795
+ );
796
+ }
797
+ },
798
+
799
+ async setValidated(sidx, value) {
800
+ const r = await fetch(`/api/sentence/${sidx}/sent_score`, {
801
+ method: 'POST',
802
+ headers: {'Content-Type': 'application/json'},
803
+ body: JSON.stringify({value}),
804
+ });
805
+ if (!r.ok) {
806
+ this.toast('Could not toggle scoring.', 'error');
807
+ return;
808
+ }
809
+ const sent = await r.json();
810
+ sent._accuracy = this.modelAccuracy(sent);
811
+ this.replaceSentence(sidx, sent);
812
+ this.toast(value ? '📊 Showing per-model accuracy vs your current annotation.' : 'Scores hidden.', 'ok');
813
+ },
814
+
815
+ // ----------- token editor -----------
816
+ onTokenClick(ev, sidx, tidx) {
817
+ if (ev.shiftKey) {
818
+ this.toggleSelectionIdx(sidx, tidx);
819
+ return;
820
+ }
821
+ this.openTokenEditor(sidx, tidx);
822
+ },
823
+
824
+ openTokenEditor(sidx, tidx) {
825
+ const sent = this.state.sentences[sidx];
826
+ const tok = JSON.parse(JSON.stringify(sent.tokens[tidx] || {}));
827
+ this.editor.sidx = sidx;
828
+ this.editor.tidx = tidx;
829
+ this.editor.tok = tok;
830
+ this.editor.original = JSON.parse(JSON.stringify(tok)); // snapshot for diff
831
+ this.editor.search = {};
832
+ this.editor.filtered = {};
833
+ this.editor.perModel = sent.per_model || {};
834
+ this.editor.disagreementCells = (sent.disagreements || []).filter(d => d.token_idx === tidx);
835
+ this.editor.propagateToSimilar = false;
836
+ this.focus = {sent: sidx, tok: tidx};
837
+ this.modal = 'token';
838
+ },
839
+
840
+ matchingTokenCount() {
841
+ if (!this.editor.tok) return 0;
842
+ const surf = this.editor.tok.surface;
843
+ let n = 0;
844
+ for (let s = 0; s < this.state.sentences.length; s++) {
845
+ for (let t = 0; t < this.state.sentences[s].tokens.length; t++) {
846
+ if (s === this.editor.sidx && t === this.editor.tidx) continue;
847
+ if (this.state.sentences[s].tokens[t].surface === surf) n++;
848
+ }
849
+ }
850
+ return n;
851
+ },
852
+
853
+ fieldChanges() {
854
+ const out = {};
855
+ if (!this.editor.tok || !this.editor.original) return out;
856
+ for (const k of Object.keys(this.editor.tok)) {
857
+ if (k === 'surface' || k.startsWith('_')) continue;
858
+ const a = JSON.stringify(this.editor.tok[k] ?? null);
859
+ const b = JSON.stringify(this.editor.original[k] ?? null);
860
+ if (a !== b) out[k] = this.editor.tok[k];
861
+ }
862
+ return out;
863
+ },
864
+
865
+ fieldChangesSummary() {
866
+ const c = this.fieldChanges();
867
+ return Object.entries(c).map(([k, v]) => {
868
+ const val = (v === null || v === undefined) ? '∅' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
869
+ return `${k}=${val}`;
870
+ }).join(', ');
871
+ },
872
+
873
+ refreshFilter(name, values) {
874
+ const q = (this.editor.search[name] || '').toLowerCase();
875
+ this.editor.filtered[name] = values.filter(v => v.toLowerCase().includes(q));
876
+ },
877
+
878
+ adoptFromModel(model) {
879
+ const sent = this.state.sentences[this.editor.sidx];
880
+ const t = (sent.per_model[model]?.tokens || [])[this.editor.tidx];
881
+ if (!t) return;
882
+ // copy all fields except surface
883
+ const surface = this.editor.tok.surface;
884
+ this.editor.tok = {...t, surface};
885
+ this.toast(`Adopted from ${this.modelShort(model)}.`, 'ok');
886
+ },
887
+
888
+ async reaskOneToken(model) {
889
+ try {
890
+ const r = await fetch('/api/annotate/token', {
891
+ method: 'POST',
892
+ headers: {'Content-Type': 'application/json', ...this.keyHeaders()},
893
+ body: JSON.stringify({
894
+ sent: this.editor.sidx, tok: this.editor.tidx, model,
895
+ })
896
+ });
897
+ if (!r.ok) throw new Error((await r.json()).detail);
898
+ const sent = await r.json();
899
+ this.replaceSentence(this.editor.sidx, sent);
900
+ // re-open with the new token
901
+ this.openTokenEditor(this.editor.sidx, this.editor.tidx);
902
+ this.toast(`Re-asked ${this.modelShort(model)}.`, 'ok');
903
+ } catch (e) {
904
+ this.toast(e.message, 'error');
905
+ }
906
+ },
907
+
908
+ async reaskOneTokenAt(sidx, tidx, model) {
909
+ this.editor.sidx = sidx;
910
+ this.editor.tidx = tidx;
911
+ await this.reaskOneToken(model);
912
+ },
913
+
914
+ async saveToken() {
915
+ const sidx = this.editor.sidx;
916
+ const tidx = this.editor.tidx;
917
+ const surface = this.editor.tok.surface;
918
+ const changes = this.fieldChanges();
919
+ const wantPropagate =
920
+ this.editor.propagateToSimilar &&
921
+ Object.keys(changes).length > 0 &&
922
+ this.matchingTokenCount() > 0;
923
+
924
+ this.editor.tok._corrected = true;
925
+
926
+ const r = await fetch(`/api/sentence/${sidx}/token/${tidx}`, {
927
  method: 'POST',
928
+ headers: {'Content-Type': 'application/json'},
929
+ body: JSON.stringify({token: this.editor.tok})
930
+ });
931
+
932
+ if (!r.ok) {
933
+ this.toast('Save failed.', 'error');
 
 
 
 
 
 
 
934
  return;
935
+ }
936
+
937
+ // returns full state to ensure consistency
938
+ const data = await r.json();
939
+ this.applyState(data);
940
+
941
+ let propagatedCount = 0;
942
+
943
+ if (wantPropagate) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
944
  try {
945
+ const r2 = await fetch('/api/bulk_similar', {
946
+ method: 'POST',
947
+ headers: {'Content-Type': 'application/json'},
948
+ body: JSON.stringify({
949
+ surface,
950
+ updates: changes,
951
+ exclude: [{s: sidx, t: tidx}],
952
+ }),
953
+ });
954
+
955
+ if (r2.ok) {
956
+ const j = await r2.json();
957
+
958
+ for (const item of (j.sentences || [])) {
959
+ this.replaceSentence(item.idx, item.sentence);
960
+ }
961
+
962
+ propagatedCount = (j.affected || []).length;
963
+ }
964
  } catch (e) {
965
+ this.toast('Propagation failed: ' + e.message, 'error');
966
  }
967
+ }
968
+
969
+ let iclMsg = '';
970
+
971
+ if (data.icl_add_result === 'updated') {
972
+ iclMsg = ` + updated ICL v${this.state.icl_pool.version}`;
973
+ } else if (data.icl_add_result === 'inserted') {
974
+ iclMsg = ` + added to ICL v${this.state.icl_pool.version}`;
975
+ } else if (data.icl_add_result === 'unchanged') {
976
+ iclMsg = ` + ICL unchanged`;
977
+ }
978
 
979
+ // auto-advance
980
+ if (this.editor.autoAdvance) {
981
  const next = this.findNextDisagreement(sidx, tidx);
982
+
983
  if (next) {
984
+ this.openTokenEditor(next.s, next.t);
985
+
986
+ this.toast(
987
+ propagatedCount > 0
988
+ ? `✓ Saved + propagated to ${propagatedCount} other "${surface}"${iclMsg}.`
989
+ : `✓ Saved${iclMsg}.`,
990
+ 'ok'
991
+ );
992
+
993
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
994
  }
995
+ }
996
+
997
+ this.closeModal();
998
+
999
+ this.toast(
1000
+ propagatedCount > 0
1001
+ ? `✓ Saved + propagated to ${propagatedCount} other "${surface}"${iclMsg}.`
1002
+ : `✓ Saved${iclMsg}.`,
1003
+ 'ok'
1004
+ );
1005
+ },
1006
+
1007
+ findNextDisagreement(sidx, tidx) {
1008
+ const sents = this.state.sentences;
1009
+ // search rest of current sentence
1010
+ const sent = sents[sidx];
1011
+ const more = (sent.disagreements || []).filter(d => d.token_idx > tidx).sort((a, b) => a.token_idx - b.token_idx);
1012
+ if (more.length > 0) return {s: sidx, t: more[0].token_idx};
1013
+ // next sentences
1014
+ for (let i = sidx + 1; i < sents.length; i++) {
1015
+ const ds = sents[i].disagreements || [];
1016
+ if (ds.length > 0) {
1017
+ const t = ds.sort((a, b) => a.token_idx - b.token_idx)[0].token_idx;
1018
+ return {s: i, t};
1019
+ }
1020
+ }
1021
+ return null;
1022
+ },
1023
+
1024
+ moveToken(delta) {
1025
+ const sent = this.state.sentences[this.editor.sidx];
1026
+ const next = this.editor.tidx + delta;
1027
+ if (next < 0 || next >= sent.tokens.length) return;
1028
+ this.openTokenEditor(this.editor.sidx, next);
1029
+ },
1030
+
1031
+ // ----------- selection / bulk -----------
1032
+ toggleSelectionIdx(sidx, tidx) {
1033
+ const k = `${sidx}:${tidx}`;
1034
+ if (this.selection.has(k)) this.selection.delete(k);
1035
+ else this.selection.add(k);
1036
+ // alpine reactivity: replace Set
1037
+ this.selection = new Set(this.selection);
1038
+ },
1039
+
1040
+ clearSelection() {
1041
+ this.selection = new Set();
1042
+ },
1043
+
1044
+ bulkSelectedField() {
1045
+ return this.schemaFields.find(f => f.name === this.bulkEditor.field);
1046
+ },
1047
+
1048
+ async applyBulk() {
1049
+ const bySent = {};
1050
+ for (const k of this.selection) {
1051
+ const [s, t] = k.split(':').map(Number);
1052
+ if (!bySent[s]) bySent[s] = [];
1053
+ bySent[s].push(t);
1054
+ }
1055
+ for (const [s, idxs] of Object.entries(bySent)) {
1056
+ const r = await fetch(`/api/sentence/${s}/bulk`, {
1057
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({
1058
+ token_idxs: idxs, field: this.bulkEditor.field, value: this.bulkEditor.value,
1059
+ })
1060
+ });
1061
+ if (r.ok) this.replaceSentence(Number(s), await r.json());
1062
+ }
1063
+ this.clearSelection();
1064
+ this.closeModal();
1065
+ this.toast('Bulk applied.', 'ok');
1066
+ },
1067
+
1068
+ // ----------- context menu -----------
1069
+ openTokenContext(ev, sidx, tidx) {
1070
+ this.ctxMenu = {open: true, x: ev.clientX, y: ev.clientY, s: sidx, t: tidx};
1071
+ },
1072
+
1073
+ // ----------- modals -----------
1074
+ closeModal() {
1075
+ this.modal = null;
1076
+ this.$nextTick(() => {
1077
+ this.editor.sidx = null;
1078
+ this.editor.tidx = null;
1079
+ this.editor.tok = null;
1080
+ this.editor.original = null;
1081
+ this.editor.perModel = {};
1082
+ this.editor.disagreementCells = [];
1083
+ });
1084
+ },
1085
+
1086
+ // ----------- keyboard -----------
1087
+ globalKey(e) {
1088
+ // editor-modal: route to editor keys
1089
+ if (this.modal === 'token' && this.editor.tok) {
1090
+ if (e.key === 'Escape') {
1091
+ this.closeModal();
1092
+ e.preventDefault();
1093
+ return;
1094
+ }
1095
+ if (e.key === 'Enter' && !(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
1096
+ this.saveToken();
1097
+ e.preventDefault();
1098
+ return;
1099
+ }
1100
+ if (e.key === 'ArrowLeft') {
1101
+ this.moveToken(-1);
1102
+ e.preventDefault();
1103
+ return;
1104
+ }
1105
+ if (e.key === 'ArrowRight') {
1106
+ this.moveToken(1);
1107
+ e.preventDefault();
1108
+ return;
1109
+ }
1110
+ // 1-9 → assign primary enum
1111
+ const num = parseInt(e.key);
1112
+ if (!isNaN(num) && num >= 1 && num <= 9) {
1113
+ const enums = this.schemaFields.filter(f => f.type === 'enum' && f.name !== 'confidence');
1114
+ if (enums.length > 0) {
1115
+ const f = enums[0];
1116
+ const visible = this.editor.filtered[f.name] || f.values;
1117
+ const v = visible[num - 1];
1118
+ if (v) {
1119
+ this.editor.tok[f.name] = v;
1120
+ e.preventDefault();
1121
+ }
1122
+ }
1123
+ }
1124
+ return;
1125
+ }
1126
+ if (this.modal) {
1127
+ if (e.key === 'Escape') {
1128
+ this.closeModal();
1129
+ e.preventDefault();
1130
+ }
1131
+ return;
1132
+ }
1133
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1134
+
1135
+ // global shortcuts
1136
+ if (e.key === 'j') {
1137
+ this.moveFocus(1);
1138
+ e.preventDefault();
1139
+ } else if (e.key === 'k') {
1140
+ this.moveFocus(-1);
1141
+ e.preventDefault();
1142
+ } else if (e.key === 'e' || e.key === 'Enter') {
1143
+ if (this.focus.sent !== null) {
1144
+ this.openTokenEditor(this.focus.sent, this.focus.tok);
1145
+ e.preventDefault();
1146
+ }
1147
+ } else if (e.key === 'x') {
1148
+ if (this.focus.sent !== null) {
1149
+ this.toggleSelectionIdx(this.focus.sent, this.focus.tok);
1150
+ e.preventDefault();
1151
+ }
1152
+ } else if (e.key === 'r') {
1153
+ if (this.focus.sent !== null) {
1154
+ this.annotateOne(this.focus.sent);
1155
+ e.preventDefault();
1156
+ }
1157
+ } else if (e.key === 'Escape') {
1158
+ this.clearSelection();
1159
+ }
1160
+ },
1161
+
1162
+ handleKey(e) { /* main panel passthrough — globalKey handles all */
1163
+ },
1164
+
1165
+ moveFocus(delta) {
1166
+ const sents = this.state.sentences;
1167
+ if (sents.length === 0) return;
1168
+ if (this.focus.sent === null) {
1169
+ this.focus = {sent: 0, tok: 0};
1170
+ return;
1171
+ }
1172
+ let s = this.focus.sent, t = this.focus.tok + delta;
1173
+ while (s >= 0 && s < sents.length) {
1174
+ if (t < 0) {
1175
+ s -= 1;
1176
+ if (s < 0) return;
1177
+ t = sents[s].tokens.length - 1;
1178
+ continue;
1179
+ }
1180
+ if (t >= sents[s].tokens.length) {
1181
+ s += 1;
1182
+ t = 0;
1183
+ continue;
1184
+ }
1185
+ this.focus = {sent: s, tok: t};
1186
+ // scroll into view
1187
+ this.$nextTick(() => {
1188
+ const el = document.querySelector(`button.token-base[data-sent="${s}"][data-tok="${t}"]`);
1189
+ if (el) el.scrollIntoView({block: 'center', behavior: 'smooth'});
1190
+ });
1191
+ return;
1192
+ }
1193
+ },
1194
+
1195
+ // ----------- toasts -----------
1196
+ toast(msg, kind = 'ok') {
1197
+ const id = this.nextToastId++;
1198
+ this.toasts.push({id, msg, kind});
1199
+ setTimeout(() => {
1200
+ this.toasts = this.toasts.filter(t => t.id !== id);
1201
+ }, 3500);
1202
+ },
1203
+
1204
+ // ----------- markdown -> html (minimal) -----------
1205
+ markdownToHtml(md) {
1206
+ // very lightweight; safe enough for trusted local content
1207
+ let h = md
1208
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1209
+ .replace(/^### (.*)$/gm, '<h3>$1</h3>')
1210
+ .replace(/^## (.*)$/gm, '<h2>$1</h2>')
1211
+ .replace(/^# (.*)$/gm, '<h1>$1</h1>')
1212
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1213
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
1214
+ .replace(/^- (.*)$/gm, '<li>$1</li>')
1215
+ .replace(/^\d+\. (.*)$/gm, '<li>$1</li>');
1216
+ h = h.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
1217
+ h = h.split(/\n{2,}/).map(p => /^<[hul]/.test(p) ? p : '<p>' + p.replace(/\n/g, '<br>') + '</p>').join('\n');
1218
+ return h;
1219
+ },
1220
+ };
1221
  }
static/index.html CHANGED
The diff for this file is too large to render. See raw diff