Scores and multi-provider
Browse files- app.py +57 -16
- provider.py +101 -47
- static/app.js +87 -15
- static/index.html +90 -33
- static/styles.css +14 -0
app.py
CHANGED
|
@@ -31,7 +31,10 @@ from io_utils import (
|
|
| 31 |
export_tsv, export_conllu, export_jsonl_finetune,
|
| 32 |
)
|
| 33 |
from moe import aggregate
|
| 34 |
-
from provider import
|
|
|
|
|
|
|
|
|
|
| 35 |
from tutorial import EXERCISES, prefill
|
| 36 |
|
| 37 |
|
|
@@ -59,7 +62,8 @@ SESSION: dict[str, Any] = {
|
|
| 59 |
"language": "",
|
| 60 |
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
| 61 |
"user_template": DEFAULT_FEW_SHOT,
|
| 62 |
-
"
|
|
|
|
| 63 |
"priority": [],
|
| 64 |
"temperature": 0.0,
|
| 65 |
"n_icl": 5,
|
|
@@ -116,7 +120,10 @@ def _public_state() -> dict:
|
|
| 116 |
},
|
| 117 |
"sentences": sess["sentences"],
|
| 118 |
"presets": [{"key": k, "label": label} for k, label in list_presets()],
|
| 119 |
-
"
|
|
|
|
|
|
|
|
|
|
| 120 |
"aggregators": AGGREGATORS,
|
| 121 |
"exercises": [
|
| 122 |
{"idx": i, "title": ex.title, "summary": ex.summary, "language": ex.language_code, "models": ex.models}
|
|
@@ -149,6 +156,7 @@ class LoadExerciseReq(BaseModel):
|
|
| 149 |
|
| 150 |
|
| 151 |
class SettingsReq(BaseModel):
|
|
|
|
| 152 |
models: Optional[list[str]] = None
|
| 153 |
priority: Optional[list[str]] = None
|
| 154 |
temperature: Optional[float] = None
|
|
@@ -168,7 +176,8 @@ class AnnotateReq(BaseModel):
|
|
| 168 |
|
| 169 |
class TestKeyReq(BaseModel):
|
| 170 |
api_key: str
|
| 171 |
-
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
# ---------------------------------------------------------------------------
|
|
@@ -223,8 +232,20 @@ def set_task_schema(req: TaskSchemaReq):
|
|
| 223 |
|
| 224 |
@app.post("/api/settings")
|
| 225 |
def set_settings(req: SettingsReq):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
if req.models is not None:
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
if req.priority is not None:
|
| 229 |
SESSION["priority"] = list(req.priority)
|
| 230 |
if req.temperature is not None:
|
|
@@ -242,7 +263,8 @@ def set_settings(req: SettingsReq):
|
|
| 242 |
|
| 243 |
@app.post("/api/settings/test_key")
|
| 244 |
def test_key(req: TestKeyReq):
|
| 245 |
-
|
|
|
|
| 246 |
return {"ok": ok, "message": msg}
|
| 247 |
|
| 248 |
|
|
@@ -308,7 +330,8 @@ def reset_all():
|
|
| 308 |
"""Wipe everything except the API key (which lives client-side)."""
|
| 309 |
SESSION["schema"] = _default_schema().to_dict()
|
| 310 |
SESSION["language"] = ""
|
| 311 |
-
SESSION["
|
|
|
|
| 312 |
SESSION["priority"] = []
|
| 313 |
SESSION["temperature"] = 0.0
|
| 314 |
SESSION["n_icl"] = 5
|
|
@@ -435,7 +458,7 @@ def icl_download():
|
|
| 435 |
|
| 436 |
# --- annotation ------------------------------------------------------------
|
| 437 |
|
| 438 |
-
async def _annotate_sentence(sent: dict, client:
|
| 439 |
schema: AnnotationSchema, sys_prompt: str,
|
| 440 |
user_template: str, language: str,
|
| 441 |
pool: ICLPool, n_icl: int, temperature: float,
|
|
@@ -495,15 +518,25 @@ async def _annotate_sentence(sent: dict, client: OpenRouterClient,
|
|
| 495 |
|
| 496 |
|
| 497 |
@app.post("/api/annotate")
|
| 498 |
-
async def annotate(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
sess = SESSION
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
| 501 |
if not api_key:
|
| 502 |
-
raise HTTPException(400, "Set your
|
| 503 |
if not sess["models"]:
|
| 504 |
raise HTTPException(400, "Select at least one model.")
|
|
|
|
|
|
|
| 505 |
schema_obj = schema_from_dict(sess["schema"])
|
| 506 |
-
client =
|
| 507 |
pool: ICLPool = sess["icl_pool"]
|
| 508 |
sents = sess["sentences"]
|
| 509 |
target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
|
|
@@ -521,12 +554,20 @@ async def annotate(req: AnnotateReq, x_openrouter_key: Optional[str] = Header(de
|
|
| 521 |
|
| 522 |
|
| 523 |
@app.post("/api/annotate/token")
|
| 524 |
-
async def annotate_one_token(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
"""Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
|
| 526 |
sess = SESSION
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
| 528 |
if not api_key:
|
| 529 |
-
raise HTTPException(400, "Set your
|
| 530 |
idx = int(payload["sent"])
|
| 531 |
tidx = int(payload["tok"])
|
| 532 |
model = str(payload["model"])
|
|
@@ -540,7 +581,7 @@ async def annotate_one_token(payload: dict, x_openrouter_key: Optional[str] = He
|
|
| 540 |
language=sess["language"] or sent["language"], sentence_id=sent["id"],
|
| 541 |
few_shot_examples=examples,
|
| 542 |
) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
|
| 543 |
-
client =
|
| 544 |
result = await client.annotate_one(
|
| 545 |
system=sess["system_prompt"], user=rendered_user,
|
| 546 |
schema=schema, model=model, temperature=float(sess["temperature"]),
|
|
|
|
| 31 |
export_tsv, export_conllu, export_jsonl_finetune,
|
| 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 |
|
|
|
|
| 62 |
"language": "",
|
| 63 |
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
| 64 |
"user_template": DEFAULT_FEW_SHOT,
|
| 65 |
+
"provider": "openrouter",
|
| 66 |
+
"models": list(CURATED_MODELS_BY_PROVIDER["openrouter"][:1]),
|
| 67 |
"priority": [],
|
| 68 |
"temperature": 0.0,
|
| 69 |
"n_icl": 5,
|
|
|
|
| 120 |
},
|
| 121 |
"sentences": sess["sentences"],
|
| 122 |
"presets": [{"key": k, "label": label} for k, label in list_presets()],
|
| 123 |
+
"provider": sess["provider"],
|
| 124 |
+
"providers": list(PROVIDERS),
|
| 125 |
+
"curated_models": CURATED_MODELS_BY_PROVIDER.get(sess["provider"], []),
|
| 126 |
+
"curated_models_by_provider": CURATED_MODELS_BY_PROVIDER,
|
| 127 |
"aggregators": AGGREGATORS,
|
| 128 |
"exercises": [
|
| 129 |
{"idx": i, "title": ex.title, "summary": ex.summary, "language": ex.language_code, "models": ex.models}
|
|
|
|
| 156 |
|
| 157 |
|
| 158 |
class SettingsReq(BaseModel):
|
| 159 |
+
provider: Optional[str] = None
|
| 160 |
models: Optional[list[str]] = None
|
| 161 |
priority: Optional[list[str]] = None
|
| 162 |
temperature: Optional[float] = None
|
|
|
|
| 176 |
|
| 177 |
class TestKeyReq(BaseModel):
|
| 178 |
api_key: str
|
| 179 |
+
provider: Optional[str] = "openrouter"
|
| 180 |
+
model: Optional[str] = None
|
| 181 |
|
| 182 |
|
| 183 |
# ---------------------------------------------------------------------------
|
|
|
|
| 232 |
|
| 233 |
@app.post("/api/settings")
|
| 234 |
def set_settings(req: SettingsReq):
|
| 235 |
+
if req.provider is not None:
|
| 236 |
+
if req.provider not in PROVIDERS:
|
| 237 |
+
raise HTTPException(400, f"Unknown provider {req.provider!r}; expected one of {list(PROVIDERS)}")
|
| 238 |
+
if req.provider != SESSION["provider"]:
|
| 239 |
+
SESSION["provider"] = req.provider
|
| 240 |
+
# reset to the new provider's first curated model (if any), to avoid orphan slugs
|
| 241 |
+
curated = CURATED_MODELS_BY_PROVIDER.get(req.provider) or []
|
| 242 |
+
SESSION["models"] = list(curated[:1])
|
| 243 |
if req.models is not None:
|
| 244 |
+
models = list(req.models)
|
| 245 |
+
# Non-OpenRouter providers don't support MoE → keep a single model
|
| 246 |
+
if SESSION["provider"] != "openrouter" and len(models) > 1:
|
| 247 |
+
models = models[:1]
|
| 248 |
+
SESSION["models"] = models
|
| 249 |
if req.priority is not None:
|
| 250 |
SESSION["priority"] = list(req.priority)
|
| 251 |
if req.temperature is not None:
|
|
|
|
| 263 |
|
| 264 |
@app.post("/api/settings/test_key")
|
| 265 |
def test_key(req: TestKeyReq):
|
| 266 |
+
provider = req.provider or "openrouter"
|
| 267 |
+
ok, msg = test_connection_sync(req.api_key, provider=provider, model=req.model)
|
| 268 |
return {"ok": ok, "message": msg}
|
| 269 |
|
| 270 |
|
|
|
|
| 330 |
"""Wipe everything except the API key (which lives client-side)."""
|
| 331 |
SESSION["schema"] = _default_schema().to_dict()
|
| 332 |
SESSION["language"] = ""
|
| 333 |
+
SESSION["provider"] = "openrouter"
|
| 334 |
+
SESSION["models"] = list(CURATED_MODELS_BY_PROVIDER["openrouter"][:1])
|
| 335 |
SESSION["priority"] = []
|
| 336 |
SESSION["temperature"] = 0.0
|
| 337 |
SESSION["n_icl"] = 5
|
|
|
|
| 458 |
|
| 459 |
# --- annotation ------------------------------------------------------------
|
| 460 |
|
| 461 |
+
async def _annotate_sentence(sent: dict, client: LLMClient,
|
| 462 |
schema: AnnotationSchema, sys_prompt: str,
|
| 463 |
user_template: str, language: str,
|
| 464 |
pool: ICLPool, n_icl: int, temperature: float,
|
|
|
|
| 518 |
|
| 519 |
|
| 520 |
@app.post("/api/annotate")
|
| 521 |
+
async def annotate(
|
| 522 |
+
req: AnnotateReq,
|
| 523 |
+
x_api_key: Optional[str] = Header(default=None),
|
| 524 |
+
x_openrouter_key: Optional[str] = Header(default=None), # back-compat
|
| 525 |
+
x_llm_provider: Optional[str] = Header(default=None),
|
| 526 |
+
):
|
| 527 |
sess = SESSION
|
| 528 |
+
provider = (x_llm_provider or sess["provider"]).strip()
|
| 529 |
+
if provider not in PROVIDERS:
|
| 530 |
+
raise HTTPException(400, f"Unknown provider {provider!r}")
|
| 531 |
+
api_key = _resolve_key(x_api_key or x_openrouter_key)
|
| 532 |
if not api_key:
|
| 533 |
+
raise HTTPException(400, f"Set your {provider} API key first.")
|
| 534 |
if not sess["models"]:
|
| 535 |
raise HTTPException(400, "Select at least one model.")
|
| 536 |
+
if provider != "openrouter" and len(sess["models"]) > 1:
|
| 537 |
+
raise HTTPException(400, f"MoE (multiple models) is only supported on OpenRouter. Pick one model for {provider}.")
|
| 538 |
schema_obj = schema_from_dict(sess["schema"])
|
| 539 |
+
client = LLMClient(provider=provider, api_key=api_key)
|
| 540 |
pool: ICLPool = sess["icl_pool"]
|
| 541 |
sents = sess["sentences"]
|
| 542 |
target_idxs = req.sentence_idxs if req.sentence_idxs is not None else list(range(len(sents)))
|
|
|
|
| 554 |
|
| 555 |
|
| 556 |
@app.post("/api/annotate/token")
|
| 557 |
+
async def annotate_one_token(
|
| 558 |
+
payload: dict,
|
| 559 |
+
x_api_key: Optional[str] = Header(default=None),
|
| 560 |
+
x_openrouter_key: Optional[str] = Header(default=None),
|
| 561 |
+
x_llm_provider: Optional[str] = Header(default=None),
|
| 562 |
+
):
|
| 563 |
"""Re-ask a specific model for a specific token. payload = {sent: int, tok: int, model: str}"""
|
| 564 |
sess = SESSION
|
| 565 |
+
provider = (x_llm_provider or sess["provider"]).strip()
|
| 566 |
+
if provider not in PROVIDERS:
|
| 567 |
+
raise HTTPException(400, f"Unknown provider {provider!r}")
|
| 568 |
+
api_key = _resolve_key(x_api_key or x_openrouter_key)
|
| 569 |
if not api_key:
|
| 570 |
+
raise HTTPException(400, f"Set your {provider} API key first.")
|
| 571 |
idx = int(payload["sent"])
|
| 572 |
tidx = int(payload["tok"])
|
| 573 |
model = str(payload["model"])
|
|
|
|
| 581 |
language=sess["language"] or sent["language"], sentence_id=sent["id"],
|
| 582 |
few_shot_examples=examples,
|
| 583 |
) + f"\n\nFocus especially on token index {tidx} (surface={tokens[tidx]!r}). Return JSON for all tokens; preserve the order."
|
| 584 |
+
client = LLMClient(provider=provider, api_key=api_key)
|
| 585 |
result = await client.annotate_one(
|
| 586 |
system=sess["system_prompt"], user=rendered_user,
|
| 587 |
schema=schema, model=model, temperature=float(sess["temperature"]),
|
provider.py
CHANGED
|
@@ -1,8 +1,13 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -10,26 +15,50 @@ import asyncio
|
|
| 10 |
import json
|
| 11 |
import time
|
| 12 |
from dataclasses import dataclass
|
| 13 |
-
from typing import
|
| 14 |
|
| 15 |
import httpx
|
| 16 |
|
| 17 |
from schemas import AnnotationSchema, to_json_schema, validate as schema_validate
|
| 18 |
from prompts import VALIDATION_RETRY
|
| 19 |
|
| 20 |
-
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 21 |
DEFAULT_TIMEOUT = 60.0
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
@dataclass
|
|
@@ -42,15 +71,26 @@ class ModelResult:
|
|
| 42 |
raw: str = ""
|
| 43 |
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
self.api_key = api_key
|
| 48 |
-
self.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
"X-Title": app_name,
|
| 52 |
-
"Content-Type": "application/json",
|
| 53 |
-
}
|
| 54 |
|
| 55 |
async def annotate_one(
|
| 56 |
self,
|
|
@@ -71,7 +111,6 @@ class OpenRouterClient:
|
|
| 71 |
raw_text = await self._call(client, msgs, json_schema, model, temperature)
|
| 72 |
ann, err = self._parse_and_validate(raw_text, schema)
|
| 73 |
if err:
|
| 74 |
-
# one retry with validator error appended
|
| 75 |
retry_msg = VALIDATION_RETRY + f"\n\nValidator errors:\n{err}\n\nPrevious response:\n{raw_text}"
|
| 76 |
msgs.append({"role": "assistant", "content": raw_text})
|
| 77 |
msgs.append({"role": "user", "content": retry_msg})
|
|
@@ -102,28 +141,32 @@ class OpenRouterClient:
|
|
| 102 |
return await asyncio.gather(*coros)
|
| 103 |
|
| 104 |
async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str, temperature: float) -> str:
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
"type": "
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
if resp.status_code >= 400:
|
| 116 |
-
# Some models don't honor strict json_schema; fall back to json_object then trust prompt instructions.
|
| 117 |
payload["response_format"] = {"type": "json_object"}
|
| 118 |
-
resp = await client.post(
|
| 119 |
resp.raise_for_status()
|
| 120 |
data = resp.json()
|
| 121 |
return data["choices"][0]["message"]["content"] or ""
|
| 122 |
|
| 123 |
@staticmethod
|
| 124 |
def _parse_and_validate(raw_text: str, schema: AnnotationSchema) -> tuple[Optional[dict], str]:
|
| 125 |
-
text = raw_text.strip()
|
| 126 |
-
# tolerate ```json ... ``` fences
|
| 127 |
if text.startswith("```"):
|
| 128 |
text = text.strip("`")
|
| 129 |
if text.lower().startswith("json"):
|
|
@@ -139,15 +182,20 @@ class OpenRouterClient:
|
|
| 139 |
return ann, ""
|
| 140 |
|
| 141 |
|
| 142 |
-
def test_connection_sync(api_key: str,
|
| 143 |
-
"""Quick blocking test for the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
try:
|
| 145 |
resp = httpx.post(
|
| 146 |
-
|
| 147 |
-
headers=
|
| 148 |
-
"Authorization": f"Bearer {api_key}",
|
| 149 |
-
"Content-Type": "application/json",
|
| 150 |
-
},
|
| 151 |
json={
|
| 152 |
"model": model,
|
| 153 |
"messages": [{"role": "user", "content": "Reply with the single word: OK"}],
|
|
@@ -159,6 +207,12 @@ def test_connection_sync(api_key: str, model: str = "openai/gpt-oss-20b:free") -
|
|
| 159 |
if resp.status_code >= 400:
|
| 160 |
return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
|
| 161 |
content = resp.json()["choices"][0]["message"]["content"]
|
| 162 |
-
return True, f"Connected. Reply: {content!r}"
|
| 163 |
except Exception as e:
|
| 164 |
return False, str(e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM provider client for structured-output annotation.
|
| 2 |
|
| 3 |
+
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
|
| 10 |
+
on OpenRouter — we auto-fall back if the strict mode is rejected).
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
|
|
|
| 15 |
import json
|
| 16 |
import time
|
| 17 |
from dataclasses import dataclass
|
| 18 |
+
from typing import Optional
|
| 19 |
|
| 20 |
import httpx
|
| 21 |
|
| 22 |
from schemas import AnnotationSchema, to_json_schema, validate as schema_validate
|
| 23 |
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",
|
| 38 |
+
"google/gemma-4-26b-a4b-it:free",
|
| 39 |
+
"meta-llama/llama-3.3-70b-instruct:free",
|
| 40 |
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
| 41 |
+
"deepseek/deepseek-v4-flash:free",
|
| 42 |
+
"mistralai/mistral-nemo",
|
| 43 |
+
"mistralai/mistral-small-24b-instruct-2501",
|
| 44 |
+
"mistralai/ministral-3b-2512",
|
| 45 |
+
],
|
| 46 |
+
"mistral": [
|
| 47 |
+
"mistral-small-2603",
|
| 48 |
+
"mistral-large-2512",
|
| 49 |
+
"ministral-8b-2512",
|
| 50 |
+
"ministral-3b-2512",
|
| 51 |
+
],
|
| 52 |
+
"openai": [
|
| 53 |
+
"gpt-5-mini-2025-08-07",
|
| 54 |
+
"gpt-5-nano-2025-08-07",
|
| 55 |
+
"gpt-5-2025-08-07",
|
| 56 |
+
"gpt-4o-mini-2024-07-18",
|
| 57 |
+
],
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# Back-compat alias used by other modules
|
| 61 |
+
CURATED_MODELS = CURATED_MODELS_BY_PROVIDER["openrouter"]
|
| 62 |
|
| 63 |
|
| 64 |
@dataclass
|
|
|
|
| 71 |
raw: str = ""
|
| 72 |
|
| 73 |
|
| 74 |
+
def _build_headers(provider: str, api_key: str) -> dict:
|
| 75 |
+
h = {
|
| 76 |
+
"Authorization": f"Bearer {api_key}",
|
| 77 |
+
"Content-Type": "application/json",
|
| 78 |
+
}
|
| 79 |
+
if provider == "openrouter":
|
| 80 |
+
h["HTTP-Referer"] = "https://lrec2026-llm-annotator.local"
|
| 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,
|
|
|
|
| 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:
|
|
|
|
| 114 |
retry_msg = VALIDATION_RETRY + f"\n\nValidator errors:\n{err}\n\nPrevious response:\n{raw_text}"
|
| 115 |
msgs.append({"role": "assistant", "content": raw_text})
|
| 116 |
msgs.append({"role": "user", "content": retry_msg})
|
|
|
|
| 141 |
return await asyncio.gather(*coros)
|
| 142 |
|
| 143 |
async def _call(self, client: httpx.AsyncClient, msgs: list[dict], json_schema: dict, model: str, temperature: float) -> str:
|
| 144 |
+
# Strict json_schema works on OpenAI and most OpenRouter models. For Mistral and
|
| 145 |
+
# for some open-source models routed via OpenRouter, fall back to json_object.
|
| 146 |
+
if self.provider == "mistral":
|
| 147 |
+
payload = {
|
| 148 |
+
"model": model, "messages": msgs, "temperature": temperature,
|
| 149 |
+
"response_format": {"type": "json_object"},
|
| 150 |
+
}
|
| 151 |
+
else:
|
| 152 |
+
payload = {
|
| 153 |
+
"model": model, "messages": msgs, "temperature": temperature,
|
| 154 |
+
"response_format": {
|
| 155 |
+
"type": "json_schema",
|
| 156 |
+
"json_schema": {"name": "annotation", "strict": True, "schema": json_schema},
|
| 157 |
+
},
|
| 158 |
+
}
|
| 159 |
+
resp = await client.post(self.endpoint, headers=self.headers, json=payload)
|
| 160 |
if resp.status_code >= 400:
|
|
|
|
| 161 |
payload["response_format"] = {"type": "json_object"}
|
| 162 |
+
resp = await client.post(self.endpoint, headers=self.headers, json=payload)
|
| 163 |
resp.raise_for_status()
|
| 164 |
data = resp.json()
|
| 165 |
return data["choices"][0]["message"]["content"] or ""
|
| 166 |
|
| 167 |
@staticmethod
|
| 168 |
def _parse_and_validate(raw_text: str, schema: AnnotationSchema) -> tuple[Optional[dict], str]:
|
| 169 |
+
text = (raw_text or "").strip()
|
|
|
|
| 170 |
if text.startswith("```"):
|
| 171 |
text = text.strip("`")
|
| 172 |
if text.lower().startswith("json"):
|
|
|
|
| 182 |
return ann, ""
|
| 183 |
|
| 184 |
|
| 185 |
+
def test_connection_sync(api_key: str, provider: str = "openrouter", model: Optional[str] = None) -> tuple[bool, str]:
|
| 186 |
+
"""Quick blocking test for the 'Test' button in the key modal."""
|
| 187 |
+
if provider not in BASE_URLS:
|
| 188 |
+
return False, f"Unknown provider {provider!r}"
|
| 189 |
+
if not model:
|
| 190 |
+
models = CURATED_MODELS_BY_PROVIDER.get(provider) or []
|
| 191 |
+
model = models[0] if models else None
|
| 192 |
+
if not model:
|
| 193 |
+
return False, "No model configured for this provider."
|
| 194 |
+
url = BASE_URLS[provider] + "/chat/completions"
|
| 195 |
try:
|
| 196 |
resp = httpx.post(
|
| 197 |
+
url,
|
| 198 |
+
headers=_build_headers(provider, api_key),
|
|
|
|
|
|
|
|
|
|
| 199 |
json={
|
| 200 |
"model": model,
|
| 201 |
"messages": [{"role": "user", "content": "Reply with the single word: OK"}],
|
|
|
|
| 207 |
if resp.status_code >= 400:
|
| 208 |
return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
|
| 209 |
content = resp.json()["choices"][0]["message"]["content"]
|
| 210 |
+
return True, f"Connected ({provider} / {model}). Reply: {content!r}"
|
| 211 |
except Exception as e:
|
| 212 |
return False, str(e)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# Back-compat shim — old callers used OpenRouterClient(api_key=...).
|
| 216 |
+
class OpenRouterClient(LLMClient):
|
| 217 |
+
def __init__(self, api_key: str, **kwargs):
|
| 218 |
+
super().__init__(provider="openrouter", api_key=api_key)
|
static/app.js
CHANGED
|
@@ -16,7 +16,8 @@ function annotator() {
|
|
| 16 |
guideDismissed: false,
|
| 17 |
moeBannerDismissed: false,
|
| 18 |
moeHintDismissed: false,
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
state: {
|
| 22 |
schema: null,
|
|
@@ -26,6 +27,9 @@ function annotator() {
|
|
| 26 |
system_prompt: '',
|
| 27 |
user_template: '',
|
| 28 |
has_env_key: false,
|
|
|
|
|
|
|
|
|
|
| 29 |
models: [],
|
| 30 |
priority: [],
|
| 31 |
temperature: 0,
|
|
@@ -80,15 +84,25 @@ function annotator() {
|
|
| 80 |
get totalDisagreements() {
|
| 81 |
return this.state.sentences.reduce((a, s) => a + (s.n_disagreements || 0), 0);
|
| 82 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
get hasKey() {
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
},
|
| 86 |
get canRun() {
|
| 87 |
return this.hasKey && this.state.models.length > 0 && this.state.sentences.length > 0;
|
| 88 |
},
|
| 89 |
keyHeaders() {
|
| 90 |
-
|
| 91 |
-
|
|
|
|
| 92 |
},
|
| 93 |
|
| 94 |
// ----------- init -----------
|
|
@@ -96,7 +110,17 @@ function annotator() {
|
|
| 96 |
this.guideDismissed = localStorage.getItem('guideDismissed') === '1';
|
| 97 |
this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1';
|
| 98 |
this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1';
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
await this.refresh();
|
| 101 |
try {
|
| 102 |
const r = await fetch('/api/cheatsheet');
|
|
@@ -297,6 +321,51 @@ function annotator() {
|
|
| 297 |
return parts[parts.length - 1];
|
| 298 |
},
|
| 299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
modelTokenSummary(ann, tidx) {
|
| 301 |
const t = (ann.tokens || [])[tidx] || {};
|
| 302 |
const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence');
|
|
@@ -335,6 +404,12 @@ function annotator() {
|
|
| 335 |
this.applyState(await r.json());
|
| 336 |
},
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
saveKey() {
|
| 339 |
const k = (this.keyEditor.value || '').trim();
|
| 340 |
if (!k) {
|
|
@@ -345,22 +420,20 @@ function annotator() {
|
|
| 345 |
}
|
| 346 |
return;
|
| 347 |
}
|
| 348 |
-
this.
|
| 349 |
-
try { sessionStorage.setItem('openrouter_key', k); } catch (e) {}
|
| 350 |
this.keyEditor.value = '';
|
| 351 |
this.keyEditor.result = '';
|
| 352 |
this.keyEditor.ok = false;
|
| 353 |
-
this.toast(`✓
|
| 354 |
this.closeModal();
|
| 355 |
},
|
| 356 |
|
| 357 |
clearKey() {
|
| 358 |
-
this.
|
| 359 |
-
try { sessionStorage.removeItem('openrouter_key'); } catch (e) {}
|
| 360 |
this.keyEditor.value = '';
|
| 361 |
this.keyEditor.result = '';
|
| 362 |
this.keyEditor.ok = false;
|
| 363 |
-
this.toast(
|
| 364 |
},
|
| 365 |
|
| 366 |
async testKey(autoSaveOnSuccess = false) {
|
|
@@ -369,15 +442,14 @@ function annotator() {
|
|
| 369 |
this.keyEditor.testing = true;
|
| 370 |
this.keyEditor.result = '';
|
| 371 |
try {
|
| 372 |
-
const r = await fetch('/api/settings/test_key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: k }) });
|
| 373 |
const j = await r.json();
|
| 374 |
this.keyEditor.ok = j.ok;
|
| 375 |
this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message;
|
| 376 |
if (j.ok && autoSaveOnSuccess) {
|
| 377 |
-
this.
|
| 378 |
-
try { sessionStorage.setItem('openrouter_key', k); } catch (e) {}
|
| 379 |
this.keyEditor.value = '';
|
| 380 |
-
this.toast(`✓
|
| 381 |
}
|
| 382 |
} catch (e) {
|
| 383 |
this.keyEditor.ok = false;
|
|
|
|
| 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,
|
|
|
|
| 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,
|
|
|
|
| 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 -----------
|
|
|
|
| 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');
|
|
|
|
| 321 |
return parts[parts.length - 1];
|
| 322 |
},
|
| 323 |
|
| 324 |
+
// Per-model accuracy on a single sentence, computed vs the current consensus
|
| 325 |
+
// (i.e., what the user sees after MoE aggregation + their corrections).
|
| 326 |
+
// Skips confidence/comment (same `min`/`priority` aggregators ignored for disagreements).
|
| 327 |
+
modelAccuracy(sent) {
|
| 328 |
+
if (!sent || sent.status !== 'done') 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');
|
|
|
|
| 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) {
|
|
|
|
| 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) {
|
|
|
|
| 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;
|
static/index.html
CHANGED
|
@@ -24,7 +24,7 @@
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
</script>
|
| 27 |
-
<link rel="stylesheet" href="/static/styles.css?v=
|
| 28 |
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.10/dist/cdn.min.js"></script>
|
| 29 |
</head>
|
| 30 |
<body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="annotator()" x-init="init()" x-cloak>
|
|
@@ -53,19 +53,19 @@
|
|
| 53 |
|
| 54 |
<!-- Models pill -->
|
| 55 |
<button @click="modal='models'" class="pill"
|
| 56 |
-
:class="state.models.length >= 2 ? 'pill-moe' : ''"
|
| 57 |
:title="state.models.join(', ')">
|
| 58 |
-
<span class="pill-icon" x-text="state.models.length >= 2 ? '🧠' : '🤖'"></span>
|
| 59 |
-
<span class="pill-label" x-text="state.models.length >= 2 ? 'MoE' : 'Model'"></span>
|
| 60 |
-
<span class="pill-value" x-text="state.models.length + (state.models.length >= 2 ? ' active' : '')"></span>
|
| 61 |
</button>
|
| 62 |
|
| 63 |
<!-- API key pill -->
|
| 64 |
<button @click="modal='key'" class="pill" :class="hasKey ? 'pill-ok' : 'pill-warn'"
|
| 65 |
-
:title="localKey ? '
|
| 66 |
<span class="pill-icon" x-text="hasKey ? '🔑' : '⚠'"></span>
|
| 67 |
-
<span class="pill-label">
|
| 68 |
-
<span class="pill-value" x-text="localKey ? 'tab-only' : (state.has_env_key ? 'shared env' : 'add key')"></span>
|
| 69 |
</button>
|
| 70 |
|
| 71 |
<div class="flex-1"></div>
|
|
@@ -179,8 +179,8 @@
|
|
| 179 |
</div>
|
| 180 |
</section>
|
| 181 |
|
| 182 |
-
<!-- MoE explainer —
|
| 183 |
-
<section x-show="state.models.length >= 2 && !moeBannerDismissed && state.sentences.length > 0"
|
| 184 |
class="rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 flex items-center gap-3" x-transition>
|
| 185 |
<span class="text-base">🧠</span>
|
| 186 |
<div class="flex-1">
|
|
@@ -189,7 +189,7 @@
|
|
| 189 |
</div>
|
| 190 |
<button @click="moeBannerDismissed=true" class="btn btn-ghost btn-icon">✕</button>
|
| 191 |
</section>
|
| 192 |
-
<section x-show="state.models.length === 1 && state.sentences.length > 0 && !moeHintDismissed"
|
| 193 |
class="rounded-xl border border-ink-200 bg-white px-4 py-2.5 text-xs text-ink-700 flex items-center gap-3" x-transition>
|
| 194 |
<span class="text-base">💡</span>
|
| 195 |
<div class="flex-1">
|
|
@@ -247,6 +247,19 @@
|
|
| 247 |
</button>
|
| 248 |
</header>
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
<div class="p-4">
|
| 251 |
<div class="tokens-flow">
|
| 252 |
<template x-for="(tok, tidx) in sent.tokens" :key="tidx + '-' + rev">
|
|
@@ -441,25 +454,52 @@
|
|
| 441 |
<button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
|
| 442 |
</header>
|
| 443 |
<div class="p-5 space-y-4">
|
| 444 |
-
<
|
| 445 |
-
<div
|
| 446 |
-
<
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
<
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
</div>
|
|
|
|
|
|
|
| 454 |
<div>
|
| 455 |
-
<label class="lbl">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
<div class="flex gap-2 mt-1">
|
| 457 |
-
<input class="input flex-1" type="text" x-model="modelEditor.custom" placeholder="
|
| 458 |
<button class="btn btn-secondary" @click="addCustomModel()">Add</button>
|
| 459 |
</div>
|
| 460 |
</div>
|
| 461 |
-
|
| 462 |
-
|
|
|
|
|
|
|
| 463 |
<input class="input" type="text" x-model="modelEditor.priority"
|
| 464 |
@change="saveSettings({priority: modelEditor.priority.split(',').map(s => s.trim()).filter(Boolean)})"
|
| 465 |
placeholder="model1, model2, model3">
|
|
@@ -472,24 +512,36 @@
|
|
| 472 |
<div class="modal-backdrop" x-show="modal === 'key'" @click.self="closeModal()" x-transition>
|
| 473 |
<div class="modal-card max-w-md">
|
| 474 |
<header class="modal-header">
|
| 475 |
-
<h2 class="font-semibold">
|
| 476 |
<button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
|
| 477 |
</header>
|
| 478 |
<div class="p-5 space-y-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-xs text-emerald-900">
|
| 480 |
-
<strong>🔒 Tab-scoped storage.</strong>
|
| 481 |
</div>
|
| 482 |
|
| 483 |
<template x-if="localKey">
|
| 484 |
<div class="rounded-lg bg-ink-50 border border-ink-200 p-3 text-xs flex items-center gap-2">
|
| 485 |
<span class="text-emerald-700">✓</span>
|
| 486 |
-
<span class="text-ink-700 flex-1">A key is set in this tab.</span>
|
| 487 |
-
<button class="btn btn-ghost text-xs text-red-600" @click="clearKey()">Clear
|
| 488 |
</div>
|
| 489 |
</template>
|
| 490 |
|
| 491 |
<label class="lbl" x-text="localKey ? 'Replace with a new key' : 'Paste your key, then press Enter (or click Save)'"></label>
|
| 492 |
-
<input class="input" type="password" placeholder="sk-or-v1-…"
|
| 493 |
x-model="keyEditor.value"
|
| 494 |
@keydown.enter.prevent="saveKey()"
|
| 495 |
x-ref="keyInput"
|
|
@@ -500,20 +552,25 @@
|
|
| 500 |
<div class="flex items-center gap-2">
|
| 501 |
<button class="btn btn-primary flex-1" @click="saveKey()" x-text="localKey ? 'Replace key' : 'Save key (this tab only)'"></button>
|
| 502 |
<button class="btn btn-secondary" @click="testKey(true)" :disabled="keyEditor.testing"
|
| 503 |
-
title="Test against
|
| 504 |
<span x-show="!keyEditor.testing">Test & save</span>
|
| 505 |
<span x-show="keyEditor.testing" class="spinner-sm"></span>
|
| 506 |
</button>
|
| 507 |
</div>
|
| 508 |
<div class="text-xs" x-show="keyEditor.result" :class="keyEditor.ok ? 'text-emerald-700' : 'text-red-600'" x-text="keyEditor.result"></div>
|
| 509 |
|
| 510 |
-
<template x-if="state.has_env_key">
|
| 511 |
<div class="text-[11px] text-ink-500 border-t border-ink-100 pt-2">
|
| 512 |
ⓘ A server-side <code>OPENROUTER_API_KEY</code> is also configured. If you don't set a tab-key, the server fallback is used.
|
| 513 |
</div>
|
| 514 |
</template>
|
| 515 |
|
| 516 |
-
<p class="text-[11px] text-ink-400">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
</div>
|
| 518 |
</div>
|
| 519 |
</div>
|
|
@@ -831,6 +888,6 @@
|
|
| 831 |
</template>
|
| 832 |
</div>
|
| 833 |
|
| 834 |
-
<script src="/static/app.js?v=
|
| 835 |
</body>
|
| 836 |
</html>
|
|
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
</script>
|
| 27 |
+
<link rel="stylesheet" href="/static/styles.css?v=20260516c">
|
| 28 |
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.10/dist/cdn.min.js"></script>
|
| 29 |
</head>
|
| 30 |
<body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="annotator()" x-init="init()" x-cloak>
|
|
|
|
| 53 |
|
| 54 |
<!-- Models pill -->
|
| 55 |
<button @click="modal='models'" class="pill"
|
| 56 |
+
:class="(state.provider === 'openrouter' && state.models.length >= 2) ? 'pill-moe' : ''"
|
| 57 |
:title="state.models.join(', ')">
|
| 58 |
+
<span class="pill-icon" x-text="(state.provider === 'openrouter' && state.models.length >= 2) ? '🧠' : '🤖'"></span>
|
| 59 |
+
<span class="pill-label" x-text="(state.provider === 'openrouter' && state.models.length >= 2) ? 'MoE' : 'Model'"></span>
|
| 60 |
+
<span class="pill-value" x-text="state.models.length + ((state.provider === 'openrouter' && state.models.length >= 2) ? ' active' : '')"></span>
|
| 61 |
</button>
|
| 62 |
|
| 63 |
<!-- API key pill -->
|
| 64 |
<button @click="modal='key'" class="pill" :class="hasKey ? 'pill-ok' : 'pill-warn'"
|
| 65 |
+
:title="localKey ? state.provider + ' key stored in this tab only. Click to manage.' : (state.provider === 'openrouter' && state.has_env_key ? 'Using server-side OPENROUTER_API_KEY env.' : 'No key yet for ' + state.provider)">
|
| 66 |
<span class="pill-icon" x-text="hasKey ? '🔑' : '⚠'"></span>
|
| 67 |
+
<span class="pill-label capitalize" x-text="state.provider"></span>
|
| 68 |
+
<span class="pill-value" x-text="localKey ? 'tab-only' : ((state.provider === 'openrouter' && state.has_env_key) ? 'shared env' : 'add key')"></span>
|
| 69 |
</button>
|
| 70 |
|
| 71 |
<div class="flex-1"></div>
|
|
|
|
| 179 |
</div>
|
| 180 |
</section>
|
| 181 |
|
| 182 |
+
<!-- MoE explainer — only meaningful on OpenRouter -->
|
| 183 |
+
<section x-show="state.provider === 'openrouter' && state.models.length >= 2 && !moeBannerDismissed && state.sentences.length > 0"
|
| 184 |
class="rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 flex items-center gap-3" x-transition>
|
| 185 |
<span class="text-base">🧠</span>
|
| 186 |
<div class="flex-1">
|
|
|
|
| 189 |
</div>
|
| 190 |
<button @click="moeBannerDismissed=true" class="btn btn-ghost btn-icon">✕</button>
|
| 191 |
</section>
|
| 192 |
+
<section x-show="state.provider === 'openrouter' && state.models.length === 1 && state.sentences.length > 0 && !moeHintDismissed"
|
| 193 |
class="rounded-xl border border-ink-200 bg-white px-4 py-2.5 text-xs text-ink-700 flex items-center gap-3" x-transition>
|
| 194 |
<span class="text-base">💡</span>
|
| 195 |
<div class="flex-1">
|
|
|
|
| 247 |
</button>
|
| 248 |
</header>
|
| 249 |
|
| 250 |
+
<!-- Per-model accuracy strip (vs current consensus / your corrections) -->
|
| 251 |
+
<div class="px-4 pt-2 -mb-1 flex flex-wrap items-center gap-1.5"
|
| 252 |
+
x-show="sent.status === 'done' && Object.keys(sent.per_model || {}).length > 0">
|
| 253 |
+
<span class="text-[10px] uppercase tracking-wider text-ink-500 font-semibold mr-1" title="Per-model agreement with the current consensus (updates as you correct)">vs consensus</span>
|
| 254 |
+
<template x-for="m in modelAccuracy(sent)" :key="m.model">
|
| 255 |
+
<span class="accuracy-pill" :class="accuracyClass(m.pct)"
|
| 256 |
+
:title="`${m.model}: ${m.correct}/${m.total} task-meaningful fields match`">
|
| 257 |
+
<span class="font-mono opacity-80" x-text="modelShort(m.model)"></span>
|
| 258 |
+
<strong x-text="m.pct + '%'"></strong>
|
| 259 |
+
</span>
|
| 260 |
+
</template>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
<div class="p-4">
|
| 264 |
<div class="tokens-flow">
|
| 265 |
<template x-for="(tok, tidx) in sent.tokens" :key="tidx + '-' + rev">
|
|
|
|
| 454 |
<button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
|
| 455 |
</header>
|
| 456 |
<div class="p-5 space-y-4">
|
| 457 |
+
<!-- Provider selector -->
|
| 458 |
+
<div>
|
| 459 |
+
<label class="lbl">Provider</label>
|
| 460 |
+
<div class="grid grid-cols-3 gap-2">
|
| 461 |
+
<template x-for="p in state.providers" :key="p">
|
| 462 |
+
<button class="preset-card text-xs capitalize"
|
| 463 |
+
:class="state.provider === p ? 'preset-card-active' : ''"
|
| 464 |
+
@click="setProvider(p)">
|
| 465 |
+
<div class="font-semibold" x-text="p"></div>
|
| 466 |
+
<div class="text-[10px] text-ink-500 mt-0.5 font-mono normal-case truncate"
|
| 467 |
+
x-text="p === 'openrouter' ? 'openrouter.ai/api/v1 · MoE' : (p === 'mistral' ? 'api.mistral.ai/v1' : 'api.openai.com/v1')"></div>
|
| 468 |
+
</button>
|
| 469 |
+
</template>
|
| 470 |
+
</div>
|
| 471 |
+
<p class="text-[11px] text-ink-500 mt-1.5">
|
| 472 |
+
<span x-show="state.provider === 'openrouter'">🧠 OpenRouter supports MoE — you can pick several models.</span>
|
| 473 |
+
<span x-show="state.provider !== 'openrouter'">Single-model only. For Mixture-of-Experts (parallel models + vote), use <strong>OpenRouter</strong>.</span>
|
| 474 |
+
</p>
|
| 475 |
</div>
|
| 476 |
+
|
| 477 |
+
<!-- Curated models for the active provider -->
|
| 478 |
<div>
|
| 479 |
+
<label class="lbl">Models for <span x-text="state.provider"></span></label>
|
| 480 |
+
<div class="space-y-1.5 max-h-60 overflow-y-auto border border-ink-200 rounded-lg p-1">
|
| 481 |
+
<template x-for="m in allDisplayableModels()" :key="m">
|
| 482 |
+
<label class="flex items-center gap-3 p-2 rounded hover:bg-ink-50 cursor-pointer">
|
| 483 |
+
<input type="checkbox" :checked="state.models.includes(m)" @change="toggleModel(m)">
|
| 484 |
+
<span class="font-mono text-sm" x-text="m"></span>
|
| 485 |
+
<span class="badge badge-uploaded text-[10px]" x-show="!state.curated_models.includes(m)">custom</span>
|
| 486 |
+
</label>
|
| 487 |
+
</template>
|
| 488 |
+
</div>
|
| 489 |
+
</div>
|
| 490 |
+
|
| 491 |
+
<!-- Custom slug -->
|
| 492 |
+
<div>
|
| 493 |
+
<label class="lbl">Add a custom model slug for <span x-text="state.provider"></span></label>
|
| 494 |
<div class="flex gap-2 mt-1">
|
| 495 |
+
<input class="input flex-1" type="text" x-model="modelEditor.custom" placeholder="model-id (e.g. mistral-medium-2505)">
|
| 496 |
<button class="btn btn-secondary" @click="addCustomModel()">Add</button>
|
| 497 |
</div>
|
| 498 |
</div>
|
| 499 |
+
|
| 500 |
+
<!-- MoE priority — only meaningful when OpenRouter + ≥2 models -->
|
| 501 |
+
<div x-show="state.provider === 'openrouter'">
|
| 502 |
+
<label class="lbl">MoE priority (tie-break order, comma-separated slugs)</label>
|
| 503 |
<input class="input" type="text" x-model="modelEditor.priority"
|
| 504 |
@change="saveSettings({priority: modelEditor.priority.split(',').map(s => s.trim()).filter(Boolean)})"
|
| 505 |
placeholder="model1, model2, model3">
|
|
|
|
| 512 |
<div class="modal-backdrop" x-show="modal === 'key'" @click.self="closeModal()" x-transition>
|
| 513 |
<div class="modal-card max-w-md">
|
| 514 |
<header class="modal-header">
|
| 515 |
+
<h2 class="font-semibold capitalize"><span x-text="state.provider"></span> API key</h2>
|
| 516 |
<button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button>
|
| 517 |
</header>
|
| 518 |
<div class="p-5 space-y-3">
|
| 519 |
+
<!-- Provider switcher (compact) -->
|
| 520 |
+
<div>
|
| 521 |
+
<label class="lbl">Provider</label>
|
| 522 |
+
<div class="flex gap-1.5">
|
| 523 |
+
<template x-for="p in state.providers" :key="p">
|
| 524 |
+
<button class="chip capitalize"
|
| 525 |
+
:class="state.provider === p ? 'chip-active' : ''"
|
| 526 |
+
@click="setProvider(p)" x-text="p"></button>
|
| 527 |
+
</template>
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
|
| 531 |
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-xs text-emerald-900">
|
| 532 |
+
<strong>🔒 Tab-scoped storage.</strong> Keys live only in this browser tab's <code>sessionStorage</code> (one slot per provider). Sent as <code>X-API-Key</code> + <code>X-LLM-Provider</code> headers — never stored on the server.
|
| 533 |
</div>
|
| 534 |
|
| 535 |
<template x-if="localKey">
|
| 536 |
<div class="rounded-lg bg-ink-50 border border-ink-200 p-3 text-xs flex items-center gap-2">
|
| 537 |
<span class="text-emerald-700">✓</span>
|
| 538 |
+
<span class="text-ink-700 flex-1">A <span x-text="state.provider"></span> key is set in this tab.</span>
|
| 539 |
+
<button class="btn btn-ghost text-xs text-red-600" @click="clearKey()">Clear</button>
|
| 540 |
</div>
|
| 541 |
</template>
|
| 542 |
|
| 543 |
<label class="lbl" x-text="localKey ? 'Replace with a new key' : 'Paste your key, then press Enter (or click Save)'"></label>
|
| 544 |
+
<input class="input" type="password" :placeholder="state.provider === 'openrouter' ? 'sk-or-v1-…' : (state.provider === 'openai' ? 'sk-…' : 'Mistral key')"
|
| 545 |
x-model="keyEditor.value"
|
| 546 |
@keydown.enter.prevent="saveKey()"
|
| 547 |
x-ref="keyInput"
|
|
|
|
| 552 |
<div class="flex items-center gap-2">
|
| 553 |
<button class="btn btn-primary flex-1" @click="saveKey()" x-text="localKey ? 'Replace key' : 'Save key (this tab only)'"></button>
|
| 554 |
<button class="btn btn-secondary" @click="testKey(true)" :disabled="keyEditor.testing"
|
| 555 |
+
:title="`Test against ${state.provider} and save if it works`">
|
| 556 |
<span x-show="!keyEditor.testing">Test & save</span>
|
| 557 |
<span x-show="keyEditor.testing" class="spinner-sm"></span>
|
| 558 |
</button>
|
| 559 |
</div>
|
| 560 |
<div class="text-xs" x-show="keyEditor.result" :class="keyEditor.ok ? 'text-emerald-700' : 'text-red-600'" x-text="keyEditor.result"></div>
|
| 561 |
|
| 562 |
+
<template x-if="state.has_env_key && state.provider === 'openrouter'">
|
| 563 |
<div class="text-[11px] text-ink-500 border-t border-ink-100 pt-2">
|
| 564 |
ⓘ A server-side <code>OPENROUTER_API_KEY</code> is also configured. If you don't set a tab-key, the server fallback is used.
|
| 565 |
</div>
|
| 566 |
</template>
|
| 567 |
|
| 568 |
+
<p class="text-[11px] text-ink-400">
|
| 569 |
+
Get a key →
|
| 570 |
+
<a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'openrouter'" href="https://openrouter.ai/keys">openrouter.ai/keys</a>
|
| 571 |
+
<a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'mistral'" href="https://console.mistral.ai/api-keys">console.mistral.ai/api-keys</a>
|
| 572 |
+
<a class="text-accent-600 underline" target="_blank" x-show="state.provider === 'openai'" href="https://platform.openai.com/api-keys">platform.openai.com/api-keys</a>
|
| 573 |
+
</p>
|
| 574 |
</div>
|
| 575 |
</div>
|
| 576 |
</div>
|
|
|
|
| 888 |
</template>
|
| 889 |
</div>
|
| 890 |
|
| 891 |
+
<script src="/static/app.js?v=20260516c"></script>
|
| 892 |
</body>
|
| 893 |
</html>
|
static/styles.css
CHANGED
|
@@ -292,6 +292,20 @@ textarea.input { font-family: 'JetBrains Mono', monospace; }
|
|
| 292 |
.badge-corrected { background: #d1fae5; color: #065f46; }
|
| 293 |
.badge-uploaded { background: #fef3c7; color: #92400e; }
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
/* ============================================================
|
| 296 |
Tables (in modals)
|
| 297 |
============================================================ */
|
|
|
|
| 292 |
.badge-corrected { background: #d1fae5; color: #065f46; }
|
| 293 |
.badge-uploaded { background: #fef3c7; color: #92400e; }
|
| 294 |
|
| 295 |
+
/* ============================================================
|
| 296 |
+
Per-model accuracy pills (under each sentence)
|
| 297 |
+
============================================================ */
|
| 298 |
+
.accuracy-pill {
|
| 299 |
+
display: inline-flex; align-items: center; gap: 0.35em;
|
| 300 |
+
padding: 0.1em 0.55em;
|
| 301 |
+
border-radius: 999px;
|
| 302 |
+
font-size: 11px;
|
| 303 |
+
line-height: 1.4;
|
| 304 |
+
}
|
| 305 |
+
.accuracy-pill-high { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
|
| 306 |
+
.accuracy-pill-mid { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
|
| 307 |
+
.accuracy-pill-low { background: #fee2e2; color: #b91c1c; border: 1px solid #fca5a5; }
|
| 308 |
+
|
| 309 |
/* ============================================================
|
| 310 |
Tables (in modals)
|
| 311 |
============================================================ */
|