lterriel commited on
Commit
cdf702a
·
1 Parent(s): 0dd9762

feat: add ILAAS LLM provider and improve frontend (alpine bugs)

Browse files
Files changed (5) hide show
  1. .gitignore +5 -0
  2. app.py +110 -54
  3. provider.py +65 -25
  4. static/app.js +1157 -944
  5. 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
@@ -32,12 +32,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 +51,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 +86,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 +107,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 +156,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 +296,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 +312,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 +349,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
@@ -372,7 +389,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 +419,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,7 +442,8 @@ 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
 
@@ -447,7 +475,7 @@ def add_sentence_to_icl(idx: int):
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 +500,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 +561,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 +638,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 +724,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)
 
32
  )
33
  from moe import aggregate
34
  from provider import (
35
+ LLMClient,
36
+ PROVIDERS,
37
+ BASE_URLS,
38
+ CURATED_MODELS_BY_PROVIDER,
39
+ test_connection_sync
40
  )
41
  from tutorial import EXERCISES, prefill
42
 
 
43
  STATIC_DIR = APP_DIR / "static"
44
 
45
 
 
51
  return from_preset("ud_upos_morph")
52
 
53
 
54
+ ENV_API_KEYS = {
55
+ "openrouter": os.environ.get("OPENROUTER_API_KEY", ""),
56
+ "mistral": os.environ.get("MISTRAL_API_KEY", ""),
57
+ "openai": os.environ.get("OPENAI_API_KEY", ""),
58
+ "ilaas": os.environ.get("ILAAS_API_KEY", ""),
59
+ }
60
 
61
 
62
+ def _resolve_key(provider: str, header_key: Optional[str]) -> str:
63
+ """Prefer the per-request header, fall back to provider-specific env key."""
64
+ return (header_key or "").strip() or ENV_API_KEYS.get(provider, "")
65
 
66
 
67
  SESSION: dict[str, Any] = {
 
86
  "id": sentence_id or f"s{idx + 1}",
87
  "language": language,
88
  "tokens": [{"surface": s} for s in surface_tokens],
89
+ "per_model": {}, # {model -> annotation dict}
90
+ "disagreements": [], # list of dis dicts
91
+ "status": "pending", # pending | annotating | done | error
92
  "error": "",
93
  "n_disagreements": 0,
94
+ "validated": False, # True once the user confirms this sentence as gold
95
  }
96
 
97
 
 
107
  "language": sess["language"],
108
  "system_prompt": sess["system_prompt"],
109
  "user_template": sess["user_template"],
110
+ "has_env_key": bool(ENV_API_KEYS.get(sess["provider"], "")),
111
  "models": sess["models"],
112
  "priority": sess["priority"],
113
  "temperature": sess["temperature"],
 
156
  text: str
157
  tokenizer: str = "whitespace" # whitespace | newline | as_is
158
  language: str = ""
159
+ split_per_line: bool = True # True -> one sentence per non-empty line
160
 
161
 
162
  class LoadExerciseReq(BaseModel):
 
296
  return _public_state()
297
 
298
 
299
+ def _default_models_for_provider(provider: str) -> list[str]:
300
+ """Helper to get the default model(s) for a provider, used when switching providers."""
301
+ curated = CURATED_MODELS_BY_PROVIDER.get(provider) or []
302
+ return list(curated[:1])
303
+
304
+
305
  @app.post("/api/corpus/exercise")
306
  def load_exercise(req: LoadExerciseReq):
307
  if req.idx < 0 or req.idx >= len(EXERCISES):
 
312
  SESSION["language"] = data["language_name"]
313
  SESSION["user_template"] = data["user_template"]
314
  SESSION["system_prompt"] = data["system_prompt"]
315
+ # Exercise presets may contain OpenRouter slugs. Keep them only when using OpenRouter.
316
+ if SESSION["provider"] == "openrouter":
317
+ SESSION["models"] = list(data["models"])
318
+ else:
319
+ SESSION["models"] = _default_models_for_provider(SESSION["provider"])
320
  # Seed ICL pool with the example's pre-validated sandbox sentences
321
  pool = ICLPool()
322
  for ex in data["icl_examples"]:
 
349
  SESSION["schema"] = _default_schema().to_dict()
350
  SESSION["language"] = ""
351
  SESSION["provider"] = "openrouter"
352
+ SESSION["models"] = _default_models_for_provider("openrouter")
353
  SESSION["priority"] = []
354
  SESSION["temperature"] = 0.0
355
  SESSION["n_icl"] = 5
 
389
  "updates": {"pos": "DET", "lemma": "ὁ"},
390
  "exclude": [{"s": sidx, "t": tidx}, ...] # optional, e.g. the source token
391
  }
392
+ Returns:
393
+ {
394
+ "affected": [{"s": sidx, "t": tidx}, ...],
395
+ "sentences": [{"idx": sidx, "sentence": {...}}, ...]
396
+ }
397
  """
398
  surface = payload.get("surface")
399
  updates = payload.get("updates") or {}
 
419
  ]
420
  sent["n_disagreements"] = len(sent["disagreements"])
421
  affected.append({"s": sidx, "t": tidx})
422
+ return {
423
+ "affected": affected,
424
+ "sentences": [
425
+ {"idx": i, "sentence": SESSION["sentences"][i]}
426
+ for i in sorted({a["s"] for a in affected})
427
+ ],
428
+ }
429
 
430
 
431
  @app.post("/api/sentence/{idx}/bulk")
 
442
  for ti in idxs:
443
  if 0 <= ti < len(sents[idx]["tokens"]):
444
  sents[idx]["tokens"][ti][field] = value
445
+ sents[idx]["disagreements"] = [d for d in sents[idx]["disagreements"] if
446
+ not (d["token_idx"] == ti and d["field_path"] == field)]
447
  sents[idx]["n_disagreements"] = len(sents[idx]["disagreements"])
448
  return sents[idx]
449
 
 
475
  return _public_state()
476
 
477
 
478
+ @app.post("/api/sentence/{idx}/sent_score")
479
  def set_validated(idx: int, payload: dict):
480
  """payload = {value: bool}. Toggles the user-validation flag on a sentence."""
481
  sents = SESSION["sentences"]
 
500
  # --- annotation ------------------------------------------------------------
501
 
502
  async def _annotate_sentence(sent: dict, client: LLMClient,
503
+ schema: AnnotationSchema, sys_prompt: str,
504
+ user_template: str, language: str,
505
+ pool: ICLPool, n_icl: int, temperature: float,
506
+ priority: list[str], models: list[str]) -> dict:
507
  tokens = [t["surface"] for t in sent["tokens"]]
508
  examples = pool.sample(
509
  n=int(n_icl), schema_hash=schema.hash(),
 
561
 
562
  @app.post("/api/annotate")
563
  async def annotate(
564
+ req: AnnotateReq,
565
+ x_api_key: Optional[str] = Header(default=None),
566
+ x_openrouter_key: Optional[str] = Header(default=None), # back-compat
567
+ x_llm_provider: Optional[str] = Header(default=None),
568
  ):
569
  sess = SESSION
570
  provider = (x_llm_provider or sess["provider"]).strip()
571
  if provider not in PROVIDERS:
572
  raise HTTPException(400, f"Unknown provider {provider!r}")
573
+ api_key = _resolve_key(provider, x_api_key or x_openrouter_key)
574
  if not api_key:
575
  raise HTTPException(400, f"Set your {provider} API key first.")
576
  if not sess["models"]:
577
  raise HTTPException(400, "Select at least one model.")
578
  if provider != "openrouter" and len(sess["models"]) > 1:
579
+ raise HTTPException(400,
580
+ f"MoE (multiple models) is only supported on OpenRouter. Pick one model for {provider}.")
581
  schema_obj = schema_from_dict(sess["schema"])
582
+ if provider != "openrouter":
583
+ allowed = set(CURATED_MODELS_BY_PROVIDER.get(provider) or [])
584
+ unknown = [m for m in sess["models"] if m not in allowed]
585
+ if unknown:
586
+ raise HTTPException(
587
+ 400,
588
+ f"Model(s) not available for provider {provider}: {unknown}. "
589
+ f"Pick one of: {sorted(allowed)}"
590
+ )
591
+ async with LLMClient(provider=provider, api_key=api_key) as client:
592
+ pool: ICLPool = sess["icl_pool"]
593
+ sents = sess["sentences"]
594
+ target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
595
+ coros = []
596
+ for i in target_idxs:
597
+ if 0 <= i < len(sents):
598
+ sents[i]["status"] = "annotating"
599
+ coros.append(_annotate_sentence(
600
+ sents[i], client, schema_obj, sess["system_prompt"], sess["user_template"],
601
+ sess["language"], pool, sess["n_icl"], sess["temperature"],
602
+ sess["priority"], sess["models"],
603
+ ))
604
+ await asyncio.gather(*coros)
605
  return _public_state()
606
 
607
 
608
  @app.post("/api/annotate/token")
609
  async def annotate_one_token(
610
+ payload: dict,
611
+ x_api_key: Optional[str] = Header(default=None),
612
+ x_openrouter_key: Optional[str] = Header(default=None),
613
+ x_llm_provider: Optional[str] = Header(default=None),
614
  ):
615
  """Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
616
  sess = SESSION
617
  provider = (x_llm_provider or sess["provider"]).strip()
618
+
619
  if provider not in PROVIDERS:
620
  raise HTTPException(400, f"Unknown provider {provider!r}")
621
+ api_key = _resolve_key(provider, x_api_key or x_openrouter_key)
622
  if not api_key:
623
  raise HTTPException(400, f"Set your {provider} API key first.")
624
  idx = int(payload["sent"])
625
  tidx = int(payload["tok"])
626
  model = str(payload["model"])
627
+ if idx < 0 or idx >= len(sess["sentences"]):
628
+ raise HTTPException(404, "Bad sentence idx")
629
  sent = sess["sentences"][idx]
630
+ if tidx < 0 or tidx >= len(sent["tokens"]):
631
+ raise HTTPException(404, "Bad token idx")
632
  schema = schema_from_dict(sess["schema"])
633
  tokens = [t["surface"] for t in sent["tokens"]]
634
  pool: ICLPool = sess["icl_pool"]
 
638
  language=sess["language"] or sent["language"], sentence_id=sent["id"],
639
  few_shot_examples=examples,
640
  ) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
641
+ if provider != "openrouter":
642
+ allowed = set(CURATED_MODELS_BY_PROVIDER.get(provider) or [])
643
+ if model not in allowed:
644
+ raise HTTPException(
645
+ 400,
646
+ f"Model {model!r} is not available for provider {provider}. "
647
+ f"Pick one of: {sorted(allowed)}"
648
+ )
649
+ async with LLMClient(provider=provider, api_key=api_key) as client:
650
+ result = await client.annotate_one(
651
+ system=sess["system_prompt"],
652
+ user=rendered_user,
653
+ schema=schema,
654
+ model=model,
655
+ temperature=float(sess["temperature"]),
656
+ )
657
  if not result.ok or not result.annotation:
658
  raise HTTPException(502, f"{model} failed: {result.error}")
659
+ if tidx >= len(result.annotation.get("tokens", [])):
660
+ raise HTTPException(502, f"{model} returned too few tokens.")
661
  # update only the targeted token
662
  new_tok = result.annotation["tokens"][tidx]
663
  new_tok["surface"] = tokens[tidx]
 
724
 
725
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
726
 
 
727
  if __name__ == "__main__":
728
  import uvicorn
729
+
730
  uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
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,54 @@ 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 +149,24 @@ 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,7 +176,8 @@ 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":
@@ -160,6 +197,9 @@ class LLMClient:
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
  json_schema = to_json_schema(schema)
134
  start = time.time()
135
  msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
136
  try:
137
+ client = self._client
138
+ close_after = False
139
+ if client is None:
140
+ client = httpx.AsyncClient(timeout=timeout)
141
+ close_after = True
142
+ try:
143
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
144
  ann, err = self._parse_and_validate(raw_text, schema)
145
  if err:
 
149
  raw_text = await self._call(client, msgs, json_schema, model, temperature)
150
  ann, err = self._parse_and_validate(raw_text, schema)
151
  if err:
152
+ return ModelResult(model=model, ok=False, annotation=None, latency_s=time.time() - start, error=err,
153
+ raw=raw_text)
154
  return ModelResult(model=model, ok=True, annotation=ann, latency_s=time.time() - start, raw=raw_text)
155
+ finally:
156
+ if close_after:
157
+ await client.aclose()
158
  except Exception as e:
159
  return ModelResult(model=model, ok=False, annotation=None, latency_s=time.time() - start, error=str(e))
160
 
161
  async def annotate_many(
162
+ self,
163
+ *,
164
+ models: list[str],
165
+ system: str,
166
+ user: str,
167
+ schema: AnnotationSchema,
168
+ temperature: float = 0.0,
169
+ timeout: float = DEFAULT_TIMEOUT,
170
  ) -> list[ModelResult]:
171
  coros = [
172
  self.annotate_one(
 
176
  ]
177
  return await asyncio.gather(*coros)
178
 
179
+ async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str,
180
+ temperature: float) -> str:
181
  # Strict json_schema works on OpenAI and most OpenRouter models. For Mistral and
182
  # for some open-source models routed via OpenRouter, fall back to json_object.
183
  if self.provider == "mistral":
 
197
  if resp.status_code >= 400:
198
  payload["response_format"] = {"type": "json_object"}
199
  resp = await client.post(self.endpoint, headers=self.headers, json=payload)
200
+ if resp.status_code >= 400:
201
+ payload.pop("response_format", None)
202
+ resp = await client.post(self.endpoint, headers=self.headers, json=payload)
203
  resp.raise_for_status()
204
  data = resp.json()
205
  return data["choices"][0]["message"]["content"] or ""
static/app.js CHANGED
@@ -1,948 +1,1161 @@
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
+ this.applyState(await r.json());
773
+ this.toast(`Added to ICL pool (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`, 'ok');
774
+ },
775
+
776
+ async setValidated(sidx, value) {
777
+ const r = await fetch(`/api/sentence/${sidx}/sent_score`, {
778
+ method: 'POST',
779
+ headers: {'Content-Type': 'application/json'},
780
+ body: JSON.stringify({value}),
781
+ });
782
+ if (!r.ok) {
783
+ this.toast('Could not toggle scoring.', 'error');
784
+ return;
785
+ }
786
+ const sent = await r.json();
787
+ sent._accuracy = this.modelAccuracy(sent);
788
+ this.replaceSentence(sidx, sent);
789
+ this.toast(value ? '📊 Showing per-model accuracy vs your current annotation.' : 'Scores hidden.', 'ok');
790
+ },
791
+
792
+ // ----------- token editor -----------
793
+ onTokenClick(ev, sidx, tidx) {
794
+ if (ev.shiftKey) {
795
+ this.toggleSelectionIdx(sidx, tidx);
796
+ return;
797
+ }
798
+ this.openTokenEditor(sidx, tidx);
799
+ },
800
+
801
+ openTokenEditor(sidx, tidx) {
802
+ const sent = this.state.sentences[sidx];
803
+ const tok = JSON.parse(JSON.stringify(sent.tokens[tidx] || {}));
804
+ this.editor.sidx = sidx;
805
+ this.editor.tidx = tidx;
806
+ this.editor.tok = tok;
807
+ this.editor.original = JSON.parse(JSON.stringify(tok)); // snapshot for diff
808
+ this.editor.search = {};
809
+ this.editor.filtered = {};
810
+ this.editor.perModel = sent.per_model || {};
811
+ this.editor.disagreementCells = (sent.disagreements || []).filter(d => d.token_idx === tidx);
812
+ this.editor.propagateToSimilar = false;
813
+ this.focus = {sent: sidx, tok: tidx};
814
+ this.modal = 'token';
815
+ },
816
+
817
+ matchingTokenCount() {
818
+ if (!this.editor.tok) return 0;
819
+ const surf = this.editor.tok.surface;
820
+ let n = 0;
821
+ for (let s = 0; s < this.state.sentences.length; s++) {
822
+ for (let t = 0; t < this.state.sentences[s].tokens.length; t++) {
823
+ if (s === this.editor.sidx && t === this.editor.tidx) continue;
824
+ if (this.state.sentences[s].tokens[t].surface === surf) n++;
825
+ }
826
+ }
827
+ return n;
828
+ },
829
+
830
+ fieldChanges() {
831
+ const out = {};
832
+ if (!this.editor.tok || !this.editor.original) return out;
833
+ for (const k of Object.keys(this.editor.tok)) {
834
+ if (k === 'surface' || k.startsWith('_')) continue;
835
+ const a = JSON.stringify(this.editor.tok[k] ?? null);
836
+ const b = JSON.stringify(this.editor.original[k] ?? null);
837
+ if (a !== b) out[k] = this.editor.tok[k];
838
+ }
839
+ return out;
840
+ },
841
+
842
+ fieldChangesSummary() {
843
+ const c = this.fieldChanges();
844
+ return Object.entries(c).map(([k, v]) => {
845
+ const val = (v === null || v === undefined) ? '∅' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
846
+ return `${k}=${val}`;
847
+ }).join(', ');
848
+ },
849
+
850
+ refreshFilter(name, values) {
851
+ const q = (this.editor.search[name] || '').toLowerCase();
852
+ this.editor.filtered[name] = values.filter(v => v.toLowerCase().includes(q));
853
+ },
854
+
855
+ adoptFromModel(model) {
856
+ const sent = this.state.sentences[this.editor.sidx];
857
+ const t = (sent.per_model[model]?.tokens || [])[this.editor.tidx];
858
+ if (!t) return;
859
+ // copy all fields except surface
860
+ const surface = this.editor.tok.surface;
861
+ this.editor.tok = {...t, surface};
862
+ this.toast(`Adopted from ${this.modelShort(model)}.`, 'ok');
863
+ },
864
+
865
+ async reaskOneToken(model) {
866
+ try {
867
+ const r = await fetch('/api/annotate/token', {
868
+ method: 'POST',
869
+ headers: {'Content-Type': 'application/json', ...this.keyHeaders()},
870
+ body: JSON.stringify({
871
+ sent: this.editor.sidx, tok: this.editor.tidx, model,
872
+ })
873
+ });
874
+ if (!r.ok) throw new Error((await r.json()).detail);
875
+ const sent = await r.json();
876
+ this.replaceSentence(this.editor.sidx, sent);
877
+ // re-open with the new token
878
+ this.openTokenEditor(this.editor.sidx, this.editor.tidx);
879
+ this.toast(`Re-asked ${this.modelShort(model)}.`, 'ok');
880
+ } catch (e) {
881
+ this.toast(e.message, 'error');
882
+ }
883
+ },
884
+
885
+ async reaskOneTokenAt(sidx, tidx, model) {
886
+ this.editor.sidx = sidx;
887
+ this.editor.tidx = tidx;
888
+ await this.reaskOneToken(model);
889
+ },
890
+
891
+ async saveToken() {
892
+ const sidx = this.editor.sidx, tidx = this.editor.tidx;
893
+ const surface = this.editor.tok.surface;
894
+ const changes = this.fieldChanges();
895
+ const wantPropagate = this.editor.propagateToSimilar && Object.keys(changes).length > 0 && this.matchingTokenCount() > 0;
896
+
897
+ this.editor.tok._corrected = true;
898
+ const r = await fetch(`/api/sentence/${sidx}/token/${tidx}`, {
899
+ method: 'POST',
900
+ headers: {'Content-Type': 'application/json'},
901
+ body: JSON.stringify({token: this.editor.tok})
902
+ });
903
+ if (!r.ok) {
904
+ this.toast('Save failed.', 'error');
905
+ return;
906
+ }
907
+ const sent = await r.json();
908
+ this.replaceSentence(sidx, sent);
909
+
910
+ let propagatedCount = 0;
911
+ if (wantPropagate) {
912
+ try {
913
+ const r2 = await fetch('/api/bulk_similar', {
914
+ method: 'POST',
915
+ headers: {'Content-Type': 'application/json'},
916
+ body: JSON.stringify({
917
+ surface,
918
+ updates: changes,
919
+ exclude: [{s: sidx, t: tidx}],
920
+ }),
921
+ });
922
+ if (r2.ok) {
923
+ const j = await r2.json();
924
+ for (const item of (j.sentences || [])) {
925
+ this.replaceSentence(item.idx, item.sentence);
926
+ }
927
+ propagatedCount = (j.affected || []).length;
928
+ }
929
+ } catch (e) {
930
+ this.toast('Propagation failed: ' + e.message, 'error');
931
+ }
932
+ }
933
+
934
+ // auto-advance
935
+ if (this.editor.autoAdvance) {
936
+ const next = this.findNextDisagreement(sidx, tidx);
937
+ if (next) {
938
+ this.openTokenEditor(next.s, next.t);
939
+ if (propagatedCount > 0) this.toast(`✓ Saved + propagated to ${propagatedCount} other "${surface}".`, 'ok');
940
+ return;
941
+ }
942
+ }
943
+ this.closeModal();
944
+ this.toast(propagatedCount > 0 ? `✓ Saved + propagated to ${propagatedCount} other "${surface}".` : '✓ Saved.', 'ok');
945
+ },
946
+
947
+ findNextDisagreement(sidx, tidx) {
948
+ const sents = this.state.sentences;
949
+ // search rest of current sentence
950
+ const sent = sents[sidx];
951
+ const more = (sent.disagreements || []).filter(d => d.token_idx > tidx).sort((a, b) => a.token_idx - b.token_idx);
952
+ if (more.length > 0) return {s: sidx, t: more[0].token_idx};
953
+ // next sentences
954
+ for (let i = sidx + 1; i < sents.length; i++) {
955
+ const ds = sents[i].disagreements || [];
956
+ if (ds.length > 0) {
957
+ const t = ds.sort((a, b) => a.token_idx - b.token_idx)[0].token_idx;
958
+ return {s: i, t};
959
+ }
960
+ }
961
+ return null;
962
+ },
963
+
964
+ moveToken(delta) {
965
+ const sent = this.state.sentences[this.editor.sidx];
966
+ const next = this.editor.tidx + delta;
967
+ if (next < 0 || next >= sent.tokens.length) return;
968
+ this.openTokenEditor(this.editor.sidx, next);
969
+ },
970
+
971
+ // ----------- selection / bulk -----------
972
+ toggleSelectionIdx(sidx, tidx) {
973
+ const k = `${sidx}:${tidx}`;
974
+ if (this.selection.has(k)) this.selection.delete(k);
975
+ else this.selection.add(k);
976
+ // alpine reactivity: replace Set
977
+ this.selection = new Set(this.selection);
978
+ },
979
+
980
+ clearSelection() {
981
+ this.selection = new Set();
982
+ },
983
+
984
+ bulkSelectedField() {
985
+ return this.schemaFields.find(f => f.name === this.bulkEditor.field);
986
+ },
987
+
988
+ async applyBulk() {
989
+ const bySent = {};
990
+ for (const k of this.selection) {
991
+ const [s, t] = k.split(':').map(Number);
992
+ if (!bySent[s]) bySent[s] = [];
993
+ bySent[s].push(t);
994
+ }
995
+ for (const [s, idxs] of Object.entries(bySent)) {
996
+ const r = await fetch(`/api/sentence/${s}/bulk`, {
997
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({
998
+ token_idxs: idxs, field: this.bulkEditor.field, value: this.bulkEditor.value,
999
+ })
1000
+ });
1001
+ if (r.ok) this.replaceSentence(Number(s), await r.json());
1002
+ }
1003
+ this.clearSelection();
1004
+ this.closeModal();
1005
+ this.toast('Bulk applied.', 'ok');
1006
+ },
1007
+
1008
+ // ----------- context menu -----------
1009
+ openTokenContext(ev, sidx, tidx) {
1010
+ this.ctxMenu = {open: true, x: ev.clientX, y: ev.clientY, s: sidx, t: tidx};
1011
+ },
1012
+
1013
+ // ----------- modals -----------
1014
+ closeModal() {
1015
+ this.modal = null;
1016
+ this.$nextTick(() => {
1017
+ this.editor.sidx = null;
1018
+ this.editor.tidx = null;
1019
+ this.editor.tok = null;
1020
+ this.editor.original = null;
1021
+ this.editor.perModel = {};
1022
+ this.editor.disagreementCells = [];
1023
+ });
1024
+ },
1025
+
1026
+ // ----------- keyboard -----------
1027
+ globalKey(e) {
1028
+ // editor-modal: route to editor keys
1029
+ if (this.modal === 'token' && this.editor.tok) {
1030
+ if (e.key === 'Escape') {
1031
+ this.closeModal();
1032
+ e.preventDefault();
1033
+ return;
1034
+ }
1035
+ if (e.key === 'Enter' && !(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
1036
+ this.saveToken();
1037
+ e.preventDefault();
1038
+ return;
1039
+ }
1040
+ if (e.key === 'ArrowLeft') {
1041
+ this.moveToken(-1);
1042
+ e.preventDefault();
1043
+ return;
1044
+ }
1045
+ if (e.key === 'ArrowRight') {
1046
+ this.moveToken(1);
1047
+ e.preventDefault();
1048
+ return;
1049
+ }
1050
+ // 1-9 → assign primary enum
1051
+ const num = parseInt(e.key);
1052
+ if (!isNaN(num) && num >= 1 && num <= 9) {
1053
+ const enums = this.schemaFields.filter(f => f.type === 'enum' && f.name !== 'confidence');
1054
+ if (enums.length > 0) {
1055
+ const f = enums[0];
1056
+ const visible = this.editor.filtered[f.name] || f.values;
1057
+ const v = visible[num - 1];
1058
+ if (v) {
1059
+ this.editor.tok[f.name] = v;
1060
+ e.preventDefault();
1061
+ }
1062
+ }
1063
+ }
1064
+ return;
1065
+ }
1066
+ if (this.modal) {
1067
+ if (e.key === 'Escape') {
1068
+ this.closeModal();
1069
+ e.preventDefault();
1070
+ }
1071
+ return;
1072
+ }
1073
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1074
+
1075
+ // global shortcuts
1076
+ if (e.key === 'j') {
1077
+ this.moveFocus(1);
1078
+ e.preventDefault();
1079
+ } else if (e.key === 'k') {
1080
+ this.moveFocus(-1);
1081
+ e.preventDefault();
1082
+ } else if (e.key === 'e' || e.key === 'Enter') {
1083
+ if (this.focus.sent !== null) {
1084
+ this.openTokenEditor(this.focus.sent, this.focus.tok);
1085
+ e.preventDefault();
1086
+ }
1087
+ } else if (e.key === 'x') {
1088
+ if (this.focus.sent !== null) {
1089
+ this.toggleSelectionIdx(this.focus.sent, this.focus.tok);
1090
+ e.preventDefault();
1091
+ }
1092
+ } else if (e.key === 'r') {
1093
+ if (this.focus.sent !== null) {
1094
+ this.annotateOne(this.focus.sent);
1095
+ e.preventDefault();
1096
+ }
1097
+ } else if (e.key === 'Escape') {
1098
+ this.clearSelection();
1099
+ }
1100
+ },
1101
+
1102
+ handleKey(e) { /* main panel passthrough — globalKey handles all */
1103
+ },
1104
+
1105
+ moveFocus(delta) {
1106
+ const sents = this.state.sentences;
1107
+ if (sents.length === 0) return;
1108
+ if (this.focus.sent === null) {
1109
+ this.focus = {sent: 0, tok: 0};
1110
+ return;
1111
+ }
1112
+ let s = this.focus.sent, t = this.focus.tok + delta;
1113
+ while (s >= 0 && s < sents.length) {
1114
+ if (t < 0) {
1115
+ s -= 1;
1116
+ if (s < 0) return;
1117
+ t = sents[s].tokens.length - 1;
1118
+ continue;
1119
+ }
1120
+ if (t >= sents[s].tokens.length) {
1121
+ s += 1;
1122
+ t = 0;
1123
+ continue;
1124
+ }
1125
+ this.focus = {sent: s, tok: t};
1126
+ // scroll into view
1127
+ this.$nextTick(() => {
1128
+ const el = document.querySelector(`button.token-base[data-sent="${s}"][data-tok="${t}"]`);
1129
+ if (el) el.scrollIntoView({block: 'center', behavior: 'smooth'});
1130
+ });
1131
+ return;
1132
+ }
1133
+ },
1134
+
1135
+ // ----------- toasts -----------
1136
+ toast(msg, kind = 'ok') {
1137
+ const id = this.nextToastId++;
1138
+ this.toasts.push({id, msg, kind});
1139
+ setTimeout(() => {
1140
+ this.toasts = this.toasts.filter(t => t.id !== id);
1141
+ }, 3500);
1142
+ },
1143
+
1144
+ // ----------- markdown -> html (minimal) -----------
1145
+ markdownToHtml(md) {
1146
+ // very lightweight; safe enough for trusted local content
1147
+ let h = md
1148
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1149
+ .replace(/^### (.*)$/gm, '<h3>$1</h3>')
1150
+ .replace(/^## (.*)$/gm, '<h2>$1</h2>')
1151
+ .replace(/^# (.*)$/gm, '<h1>$1</h1>')
1152
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1153
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
1154
+ .replace(/^- (.*)$/gm, '<li>$1</li>')
1155
+ .replace(/^\d+\. (.*)$/gm, '<li>$1</li>');
1156
+ h = h.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
1157
+ h = h.split(/\n{2,}/).map(p => /^<[hul]/.test(p) ? p : '<p>' + p.replace(/\n/g, '<br>') + '</p>').join('\n');
1158
+ return h;
1159
+ },
1160
+ };
1161
  }
static/index.html CHANGED
The diff for this file is too large to render. See raw diff